diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
 
 Newest first.
 
+- **2026-10-23** · Measurement and number fields read a decimal comma between digits as a decimal point (`0,1 cm`, `48,5°`), as typed in most European languages; before, a measurement with a comma was rejected and the field quietly kept its old value, and a number field dropped the comma (`48,5` became 485). Part of #1364. No readiness percentage change.
 - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
 - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
 - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -26,7 +26,7 @@ pub mod warp;
 pub use kurbo;
 pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
 pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
-pub use units::{Unit, format_measure, parse_measure};
+pub use units::{Unit, decimal_comma, format_measure, parse_measure};
 
 /// Tolerance used when comparing handle positions to anchor positions.
 pub const EPS: f64 = 1e-9;
diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs
--- a/crates/geom/src/units.rs
+++ b/crates/geom/src/units.rs
@@ -159,6 +159,7 @@ pub fn parse_measure_rel(input: &str, default: Unit, percent_base: Option<f64>)
     if s.is_empty() {
         return Err(err());
     }
+    let s = decimal_comma(&s);
     // Tokenize into terms separated by + and - (a leading sign belongs to the first term).
     let mut terms: Vec<(f64, String)> = Vec::new();
     let mut sign = 1.0;
@@ -184,6 +185,29 @@ pub fn parse_measure_rel(input: &str, default: Unit, percent_base: Option<f64>)
     if total.is_finite() { Ok(total) } else { Err(err()) }
 }
 
+/// A comma between two digits read as a decimal point (`0,5 cm`, `12,75`), as typed in most
+/// European languages, when the text has no dot of its own (so `1.000,5` isn't guessed at). A
+/// comma before exactly three digits (`1,000`, `2,500 mm`) could be English thousands and stays,
+/// so the field refuses it rather than reading a thousand as one.
+pub fn decimal_comma(s: &str) -> String {
+    if s.contains('.') {
+        return s.to_string();
+    }
+    let c: Vec<char> = s.chars().collect();
+    let digit = |j: Option<usize>| j.and_then(|j| c.get(j)).is_some_and(|d| d.is_ascii_digit());
+    c.iter()
+        .enumerate()
+        .map(|(i, &ch)| {
+            if ch != ',' || !digit(i.checked_sub(1)) || !digit(Some(i + 1)) {
+                return ch;
+            }
+            // Only whether exactly three digits follow: four are enough to tell.
+            let after = c.iter().skip(i + 1).take(4).take_while(|d| d.is_ascii_digit()).count();
+            if after != 3 { '.' } else { ch }
+        })
+        .collect()
+}
+
 /// `a*b/c`: the first factor carries the unit; later factors are plain numbers (or measurements,
 /// in which case only the first operand's unit counts, matching InDesign's arithmetic).
 fn parse_product(term: &str, default: Unit, percent_base: Option<f64>) -> Option<f64> {
@@ -323,6 +347,31 @@ mod tests {
         assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0");
     }
 
+    /// A decimal comma, as German, French or Spanish keyboards type it, reads as a decimal point
+    /// (it was rejected, so the field quietly kept its old value).
+    #[test]
+    fn a_decimal_comma_reads_as_a_decimal_point() {
+        let cm = Unit::Centimeters.to_pt(1.0);
+        assert!((parse_measure("0,1", Unit::Centimeters).unwrap() - 0.1 * cm).abs() < 1e-9);
+        assert!((parse_measure("0,5 cm", Unit::Points).unwrap() - 0.5 * cm).abs() < 1e-9);
+        assert!((parse_measure("1,5+0,25", Unit::Points).unwrap() - 1.75).abs() < 1e-9);
+        assert!((parse_measure("12,5mm", Unit::Points).unwrap() - Unit::Millimeters.to_pt(12.5)).abs() < 1e-9);
+        // With a dot of its own the text isn't guessed at, and a comma without digits on both
+        // sides stays an error.
+        assert!(parse_measure("1.000,5", Unit::Points).is_err());
+        assert!(parse_measure(",5", Unit::Points).is_err());
+        assert!(parse_measure("5,", Unit::Points).is_err());
+        // Before exactly three digits a comma may be English thousands: refused, not guessed.
+        assert!(parse_measure("1,000", Unit::Points).is_err());
+        // Each comma looks at most four digits ahead: a very long number stays quick.
+        let long = format!("1,{}", "5".repeat(50_000));
+        let t = std::time::Instant::now();
+        assert_eq!(decimal_comma(&long).chars().nth(1), Some('.'));
+        assert!(t.elapsed() < std::time::Duration::from_millis(500), "{:?}", t.elapsed());
+        assert!(parse_measure("2,500 mm", Unit::Points).is_err());
+        assert!((parse_measure("48,25", Unit::Points).unwrap() - 48.25).abs() < 1e-9);
+    }
+
     /// Inches show four decimals, as InDesign's fields do; other units three.
     #[test]
     fn inches_show_four_decimals() {
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -49,6 +49,13 @@ impl NumKind<'_> {
             NumKind::Measure(u) => parse_measure(s, u).ok(),
             NumKind::Number { .. } => {
                 // Numbers accept the same arithmetic as measures (`12+3`, `50%*2`): evaluate as points.
+                // A decimal comma is a decimal point (`48,5°` is 48.5, not 485).
+                let s = designcraft_geom::decimal_comma(s.trim());
+                // A comma it couldn't read (`1.000,5`, `,5`, `1,000`) refuses the input rather
+                // than being dropped into another number.
+                if s.contains(',') {
+                    return None;
+                }
                 let cleaned: String =
                     s.chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '*' | '/' | '(' | ')' | ' ')).collect();
                 parse_measure(cleaned.trim(), Unit::Points).ok().or_else(|| cleaned.trim().parse().ok())
@@ -735,6 +742,22 @@ mod tests {
         (got, rect)
     }
 
+    /// Number fields (angles, percentages) read a decimal comma as a decimal point: `48,5`
+    /// was read as 485.
+    #[test]
+    fn number_fields_read_a_decimal_comma() {
+        let deg = NumKind::Number { suffix: "°", decimals: 1 };
+        assert_eq!(deg.parse("48,5"), Some(48.5));
+        assert_eq!(deg.parse("48,5°"), Some(48.5));
+        assert_eq!(deg.parse("48.5"), Some(48.5));
+        let cm = NumKind::Measure(Unit::Centimeters);
+        assert_eq!(cm.parse("0,1"), Some(Unit::Centimeters.to_pt(0.1)));
+        // A comma that can't be read refuses the input instead of vanishing.
+        for typed in ["1.000,5", ",5", "5,", "1,000"] {
+            assert_eq!(deg.parse(typed), None, "{typed}");
+        }
+    }
+
     #[test]
     fn spinner_up_down_and_shift() {
         let (_, r) = drive(vec![], 10.0);
