Files
wordcraft/crates/docx/src/units.rs
T
535b675e8a DOCX: paragraph indents and spacing beyond 16 bits wrap as in Word (#5655)
## What changed

Word stores paragraph indents (`w:ind` left/start, right/end, hanging, firstLine) in 16 signed bits and paragraph
spacing (`w:spacing` before/after) in 16 unsigned bits. A file that writes a larger plain integer is read by Word
modulo 2^16: `w:left="66256"` is a 720-twip (0.5") indent in Word. We read the full value, so such a paragraph was
indented by about 46 inches and its text was pushed off the page.

- `crates/docx/src/units.rs`: new `tw_i16` / `tw_u16` readers. A plain integer keeps its low 16 bits (two's
  complement for indents, so 66256 -> 720 and 65535 -> -1); values with a unit or a fraction read as before.
- `crates/docx/src/read/props.rs` and `read/styles.rs` use them for `w:ind` and `w:spacing` before/after, in direct
  and style formatting.
- In-range values read exactly as before.

## Testing

- `crates/docx/tests/it/twips_wrap.rs`: synthetic documents with out-of-range indents and spacing in paragraph and
  style properties check the wrapped values; in-range values and values with units are unchanged.
- Unit tests for the two readers; `cargo xtask ci` passes.

Found by a real-file corpus comparison against Word's own PDFs: a file whose indents exceed 16 bits shows its text on
the page in Word and off the page here.

Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:39:22 +01:00

223 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Lenient number parsing for OOXML attribute values.
use crate::xml::El;
/// Largest absolute length we accept, points (beyond any real page: 200 inches).
pub const MAX_LEN_PT: f32 = 14_400.0;
/// Parse a number, accepting decimals and ISO 29500 universal measures (`1in`, `2.5cm`, `12pt`,
/// `3mm`, `1pc`, `1pi`). `per_pt` is how many file units make a point when there's no unit
/// suffix (20 for twips, 2 for half-points, 8 for eighths, 12700 for EMU). Returns points.
pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
let bytes = t.as_bytes();
let mut split = t.len();
let mut in_exp = false;
for (i, &b) in bytes.iter().enumerate() {
if b.is_ascii_digit() || b == b'.' {
in_exp = false;
continue;
}
if (b == b'-' || b == b'+') && (i == 0 || in_exp) {
in_exp = false;
continue;
}
if b == b'e' || b == b'E' {
let rest = &bytes[i + 1..];
let after_sign = match rest.first() {
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest,
};
if after_sign.first().is_some_and(u8::is_ascii_digit) {
in_exp = true;
continue;
}
}
split = i;
break;
}
let (num, unit) = t.split_at(split);
let v: f64 = num.parse().ok()?;
if !v.is_finite() {
return None;
}
let unit_lower = unit.trim().to_ascii_lowercase();
let pt = match unit_lower.as_str() {
"" => v / per_pt as f64,
"pt" => v,
"in" => v * 72.0,
"cm" => v * 72.0 / 2.54,
"mm" => v * 72.0 / 25.4,
"pc" | "pi" => v * 12.0,
"px" => v * 0.75,
"emu" => v / 12_700.0,
_ => return None,
};
let pt = pt as f32;
pt.is_finite().then_some(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT))
}
/// `w:line` under `lineRule="auto"`: the multiple of single line spacing, in 240ths (240 =
/// single). Without a unit the value already is that — the transitional form. A universal
/// measure is a height (strict OOXML writes `w:line="13.90pt"`, ISO 29500
/// `ST_SignedTwipsMeasure`), so it goes through twips as Word reads it: × 20 ÷ 240 (13.90 pt =
/// 278 twips, the 1.1583 multiple Word renders).
pub fn line_multiple(s: &str) -> Option<f32> {
let t = s.trim();
let measured = t.ends_with(|c: char| c.is_ascii_alphabetic());
measure(t, 240.0).map(|v| if measured { v * 20.0 / 240.0 } else { v })
}
/// Plain integer (accepts decimals, rounds; clamps into i64 range).
pub fn int(s: &str) -> Option<i64> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
if let Ok(v) = t.parse::<i64>() {
return Some(v);
}
let v: f64 = t.parse().ok()?;
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
}
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
pub fn vml_number(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
let v = match t.strip_suffix('f') {
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
None => t.parse::<f64>().ok()?,
};
v.is_finite().then_some(v)
}
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
let lum = blip.child("a:lum");
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
}
pub fn u32_of(s: &str) -> Option<u32> {
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
}
/// Twips attribute → points.
pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
None => true,
Some(v) => !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE"),
}
}
/// An on/off child of `parent`, if present.
pub fn flag(parent: &El, name: &str) -> Option<bool> {
parent.child(name).map(on_off)
}
/// Format a number for an attribute: integers without a fraction.
pub fn n(v: i64) -> String {
v.to_string()
}
/// Points → twips string.
pub fn twips(pt: f32) -> String {
wordcraft_geom::to_twips(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT)).to_string()
}
/// Points → EMU string (never negative for sizes).
pub fn emu(pt: f32) -> String {
wordcraft_geom::to_emu(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT)).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
assert_eq!(measure("1in", 20.0), Some(72.0));
assert_eq!(measure("12pt", 2.0), Some(12.0));
assert_eq!(measure("24.0", 2.0), Some(12.0));
assert_eq!(measure("nan", 20.0), None);
assert_eq!(measure("1e400", 20.0), None);
assert_eq!(measure("99999999999999", 20.0), Some(MAX_LEN_PT));
assert_eq!(measure("x", 20.0), None);
// `w:line` auto: the multiple in 240ths without a unit, through twips with one (#2329).
assert_eq!(line_multiple("240"), Some(1.0));
assert_eq!(line_multiple("276"), Some(1.15));
assert_eq!(line_multiple("0"), Some(0.0));
let strict = line_multiple("13.90pt").expect("strict line height");
assert!((strict - 278.0 / 240.0).abs() < 1e-5, "{strict}");
let inches = line_multiple("1.2in").expect("inches");
assert!((inches - 7.2).abs() < 1e-5, "{inches}");
assert_eq!(line_multiple("junk"), None);
assert_eq!(line_multiple(""), None);
assert_eq!(measure("12700emu", 20.0), Some(1.0));
assert_eq!(measure("25400EMU", 20.0), Some(2.0));
assert_eq!(measure("1IN", 20.0), Some(72.0));
assert_eq!(measure("12PT", 2.0), Some(12.0));
assert_eq!(measure("2.54CM", 20.0), Some(72.0));
assert_eq!(measure("25.4MM", 20.0), Some(72.0));
assert_eq!(measure("10PX", 20.0), Some(7.5));
assert_eq!(measure("1e2pt", 20.0), Some(100.0));
assert_eq!(measure("1e+2pt", 20.0), Some(100.0));
assert_eq!(int("12.6"), Some(13));
assert_eq!(u32_of("-5"), Some(0));
}
}