Browse / github.com/storytold/craft-libs / patch
units: exact shared length conversions for the office apps
units: exact shared length conversions for the office apps ## What Adds `craft-units`, a dependency-free crate that owns length conversion for the office apps. `Length` stores an exact signed integer count of EMU and converts to points, twips, inches, centimetres, millimetres and CSS pixels. Rounding is half away from zero; conversions saturate instead of overflowing; non-finite input becomes zero; `Length::parse` reads `12.5pt`, `2cm`, `96px`, `1440tw` and `914400emu`. ## Why GridCraft, DeckCraft and WordCraft all read and write Office Open XML, which mixes EMU, twips and points in one document. Each app carried its own copy of the conversions an
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- bogdanbaciu21 (imported from pull request #314, open)
- Base
2869b8cc1077530bd07a7e192fb2b0e1304a1354- Size
- +498 −0 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
257fc20eebe5bf0ce1498842
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/units.yml Cargo.lock Cargo.toml README.md crates/units/Cargo.toml crates/units/README.md crates/units/src/lib.rs crates/units/tests/units.rs
new file mode 100644@@ -0,0 +1,29 @@+name: Units++on:+ workflow_dispatch:+ push:+ branches: [main]+ paths: ['crates/units/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/units.yml']+ pull_request:+ paths: ['crates/units/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/units.yml']++permissions:+ contents: read++jobs:+ units:+ timeout-minutes: 15+ strategy:+ fail-fast: false+ matrix:+ os: [ubuntu-latest, windows-latest, macos-latest]+ runs-on: ${{ matrix.os }}+ steps:+ - uses: actions/checkout@v4+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable+ with:+ components: rustfmt, clippy+ - run: cargo fmt --check+ - run: cargo clippy -p craft-units --all-targets --locked -- -D warnings+ - run: cargo test -p craft-units --locked -- --nocapture@@ -997,6 +997,13 @@ dependencies = [ "serde_json", ] +[[package]]+name = "craft-units"+version = "0.1.0"+dependencies = [+ "proptest",+]+ [[package]] name = "craft-window" version = "0.1.0"@@ -36,6 +36,7 @@ craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } craft-ui = { path = "crates/ui" }+craft-units = { path = "crates/units" } craft-window = { path = "crates/window" } egui = { version = "0.36", default-features = false } rayon = "1"@@ -63,6 +63,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit - [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md). - [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends; assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.+- [`craft-units`](crates/units/README.md): exact integer length units for the office apps — EMU,+ points, twips, inches, centimetres, millimetres and CSS pixels — with half-away-from-zero+ rounding, saturation instead of overflow and zero for non-finite input. Layout stays in each app. - [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities and compatibility boundaries across the Craft suite. - [Theme infrastructure](docs/theme-infrastructure.md): eight-app comparison and the small sharednew file mode 100644@@ -0,0 +1,14 @@+[package]+name = "craft-units"+description = "Exact integer length units shared by the Craft office apps: EMU, points, twips, inches, centimetres and CSS pixels"+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true++[lints]+workspace = true++[dev-dependencies]+proptest.workspace = truenew file mode 100644@@ -0,0 +1,58 @@+# craft-units++Exact length units for the Craft office apps.++WordCraft, DeckCraft and GridCraft all read and write Office Open XML, which mixes several length+units in one document: English Metric Units (EMU) for DrawingML, twips for WordprocessingML, points+for print and font sizes, and CSS pixels for the web builds. Before this crate each app carried its+own copy of the conversions, in `f32` in one and `f64` in another, so a length could round+differently depending on which app read the file. This crate is the single implementation.++`Length` stores an exact signed integer count of EMU, the finest unit OOXML uses:++| Unit | EMU per unit |+|---|---:|+| inch | 914 400 |+| point | 12 700 |+| twip | 635 |+| centimetre | 360 000 |+| millimetre | 36 000 |+| CSS pixel (96 dpi) | 9 525 |++## Behaviour++- **Exact round trips.** A length read from a file and written back is bit-for-bit unchanged,+ because the stored value is the integer EMU count from the file.+- **Rounding half away from zero.** `0.5 pt` is `10 twips`; `-0.5 pt` is `-10 twips`.+- **Saturating, never wrapping.** Converting an out-of-range value clamps at the `i64` range.+- **Non-finite input is zero.** `NaN` and the infinities convert to zero, matching the behaviour+ the apps already had.+- **No panics on hostile input.** No `unwrap`, no indexing, no arithmetic that can overflow.++## Use++```rust+use craft_units::Length;++let page = Length::from_inches(8.5);+assert_eq!(page.emu(), 7_772_400);+assert_eq!(page.points(), 612.0);+assert_eq!(page.twips(), 12_240);++let parsed = Length::parse("2.5cm");+assert_eq!(parsed, Some(Length::from_centimetres(2.5)));+```++Nothing here decides layout, print policy or document structure; those stay in each app. The crate+owns conversion and representation only.++## Quality gates++```sh+cargo fmt -p craft-units --check+cargo clippy -p craft-units --all-targets -- -D warnings+cargo test -p craft-units+```++The crate follows the repository rules in [`../../AGENTS.md`](../../AGENTS.md): every public item is+documented, the crate denies the panic lints, and `unsafe` is forbidden at the workspace level.new file mode 100644@@ -0,0 +1,343 @@+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]+#![forbid(unsafe_code)]++//! Exact length units for the Craft office apps.+//!+//! WordCraft, DeckCraft and GridCraft all read and write Office Open XML, which mixes several+//! length units in one document: English Metric Units (EMU) for DrawingML, twips for+//! WordprocessingML, points for print and font sizes, and CSS pixels for the web builds. Each app+//! previously carried its own copy of these conversions, in `f32` in one and `f64` in another.+//! This crate is the single implementation, so a length that round-trips in one app round-trips+//! in all of them.+//!+//! [`Length`] stores an exact signed integer count of EMU, the finest unit OOXML uses+//! (914 400 per inch, 12 700 per point, 635 per twip). Converting to a coarser unit rounds half+//! away from zero, so `0.5 pt` becomes `10 twips` and `-0.5 pt` becomes `-10 twips`. Converting a+//! floating-point value to EMU clamps to the `i64` range instead of overflowing, and maps+//! non-finite input (`NaN`, infinities) to zero, matching the behaviour the apps already had.+//!+//! Nothing in this crate panics on hostile input: no `unwrap`, no indexing, no arithmetic that can+//! overflow.+//!+//! ```+//! use craft_units::Length;+//!+//! let page = Length::from_inches(8.5);+//! assert_eq!(page.emu(), 7_772_400);+//! assert_eq!(page.points(), 612.0);+//! assert_eq!(page.twips(), 12_240);+//! ```++/// EMU per inch.+pub const EMU_PER_INCH: i64 = 914_400;+/// EMU per point (1/72 inch).+pub const EMU_PER_POINT: i64 = 12_700;+/// EMU per twip (1/20 point).+pub const EMU_PER_TWIP: i64 = 635;+/// EMU per centimetre.+pub const EMU_PER_CENTIMETRE: i64 = 360_000;+/// EMU per millimetre.+pub const EMU_PER_MILLIMETRE: i64 = 36_000;+/// EMU per CSS pixel at the CSS reference density of 96 pixels per inch.+pub const EMU_PER_CSS_PIXEL: i64 = 9_525;++/// Points per inch.+pub const POINTS_PER_INCH: i64 = 72;+/// Twips per point.+pub const TWIPS_PER_POINT: i64 = 20;+/// Twips per inch.+pub const TWIPS_PER_INCH: i64 = 1_440;+/// CSS pixels per inch at the CSS reference density.+pub const CSS_PIXELS_PER_INCH: i64 = 96;++/// A length stored as an exact count of English Metric Units.+///+/// The representation is deliberately integer: OOXML lengths are integers, so a document parsed+/// and written back unchanged keeps every length bit-for-bit. Use the [`f64`] accessors for+/// layout and rendering, and [`Length::from_emu`] when reading a value straight out of a file.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]+pub struct Length(i64);++impl Length {+ /// Zero length.+ pub const ZERO: Self = Self(0);++ /// Builds a length from a raw EMU count, as stored in DrawingML attributes.+ #[must_use]+ pub const fn from_emu(emu: i64) -> Self {+ Self(emu)+ }++ /// The raw EMU count, for writing a DrawingML attribute.+ #[must_use]+ pub const fn emu(self) -> i64 {+ self.0+ }++ /// Builds a length from points. Non-finite input becomes zero; out-of-range input saturates.+ #[must_use]+ pub fn from_points(points: f64) -> Self {+ Self(scaled(points, EMU_PER_POINT))+ }++ /// The length in points.+ #[must_use]+ pub fn points(self) -> f64 {+ divided(self.0, EMU_PER_POINT)+ }++ /// Builds a length from inches. Non-finite input becomes zero; out-of-range input saturates.+ #[must_use]+ pub fn from_inches(inches: f64) -> Self {+ Self(scaled(inches, EMU_PER_INCH))+ }++ /// The length in inches.+ #[must_use]+ pub fn inches(self) -> f64 {+ divided(self.0, EMU_PER_INCH)+ }++ /// Builds a length from centimetres.+ #[must_use]+ pub fn from_centimetres(centimetres: f64) -> Self {+ Self(scaled(centimetres, EMU_PER_CENTIMETRE))+ }++ /// The length in centimetres.+ #[must_use]+ pub fn centimetres(self) -> f64 {+ divided(self.0, EMU_PER_CENTIMETRE)+ }++ /// Builds a length from millimetres.+ #[must_use]+ pub fn from_millimetres(millimetres: f64) -> Self {+ Self(scaled(millimetres, EMU_PER_MILLIMETRE))+ }++ /// The length in millimetres.+ #[must_use]+ pub fn millimetres(self) -> f64 {+ divided(self.0, EMU_PER_MILLIMETRE)+ }++ /// Builds a length from CSS pixels at the CSS reference density of 96 dpi.+ #[must_use]+ pub fn from_css_pixels(pixels: f64) -> Self {+ Self(scaled(pixels, EMU_PER_CSS_PIXEL))+ }++ /// The length in CSS pixels at the CSS reference density of 96 dpi.+ #[must_use]+ pub fn css_pixels(self) -> f64 {+ divided(self.0, EMU_PER_CSS_PIXEL)+ }++ /// Builds a length from twips, saturating instead of overflowing.+ #[must_use]+ pub const fn from_twips(twips: i64) -> Self {+ Self(twips.saturating_mul(EMU_PER_TWIP))+ }++ /// The length in twips, rounded half away from zero.+ #[must_use]+ pub fn twips(self) -> i64 {+ rounded_div(self.0, EMU_PER_TWIP)+ }++ /// Adds two lengths, returning `None` on overflow.+ #[must_use]+ pub const fn checked_add(self, other: Self) -> Option<Self> {+ match self.0.checked_add(other.0) {+ Some(sum) => Some(Self(sum)),+ None => None,+ }+ }++ /// Subtracts one length from another, returning `None` on overflow.+ #[must_use]+ pub const fn checked_sub(self, other: Self) -> Option<Self> {+ match self.0.checked_sub(other.0) {+ Some(difference) => Some(Self(difference)),+ None => None,+ }+ }++ /// Adds two lengths, clamping at the `i64` range on overflow.+ #[must_use]+ pub const fn saturating_add(self, other: Self) -> Self {+ Self(self.0.saturating_add(other.0))+ }++ /// Subtracts one length from another, clamping at the `i64` range on overflow.+ #[must_use]+ pub const fn saturating_sub(self, other: Self) -> Self {+ Self(self.0.saturating_sub(other.0))+ }++ /// Parses a measurement such as `12.5pt`, `2cm`, `1in`, `96px`, `1440tw` or `914400emu`.+ ///+ /// The number may carry a leading sign and a decimal point. The unit suffix is required and is+ /// matched ignoring case. Returns `None` for an empty string, a missing or unknown unit, a+ /// number that does not parse, or a non-finite number such as `inf` or `NaN`.+ #[must_use]+ pub fn parse(text: &str) -> Option<Self> {+ let text = text.trim();+ if text.is_empty() {+ return None;+ }+ let bytes = text.as_bytes();+ let mut unit_start = bytes.len();+ while unit_start > 0 && bytes[unit_start.saturating_sub(1)].is_ascii_alphabetic() {+ unit_start = unit_start.saturating_sub(1);+ }+ if unit_start == bytes.len() || unit_start == 0 {+ return None;+ }+ let number: f64 = text.get(..unit_start)?.trim().parse().ok()?;+ if !number.is_finite() {+ return None;+ }+ let unit = text.get(unit_start..)?;+ Some(match unit.to_ascii_lowercase().as_str() {+ "emu" => Self::from_emu(number as i64),+ "pt" | "point" | "points" => Self::from_points(number),+ "in" | "inch" | "inches" => Self::from_inches(number),+ "cm" => Self::from_centimetres(number),+ "mm" => Self::from_millimetres(number),+ "px" => Self::from_css_pixels(number),+ "tw" | "twip" | "twips" => Self::from_twips(number as i64),+ _ => return None,+ })+ }+}++/// Multiplies a finite count of whole units by the EMU size of one unit.+///+/// Non-finite input is zero, matching the apps' existing `pt_to_emu` and `to_emu` behaviour.+/// The cast from `f64` to `i64` saturates, so an enormous finite value clamps instead of wrapping.+fn scaled(value: f64, emu_per_unit: i64) -> i64 {+ if !value.is_finite() {+ return 0;+ }+ (value * emu_per_unit as f64).round() as i64+}++/// Divides an EMU count into whole units.+fn divided(emu: i64, emu_per_unit: i64) -> f64 {+ emu as f64 / emu_per_unit as f64+}++/// Divides `numerator` by a positive `denominator`, rounding half away from zero.+///+/// Computed in `i128` so `numerator * 2` cannot overflow, then clamped back into `i64`.+fn rounded_div(numerator: i64, denominator: i64) -> i64 {+ let numerator = i128::from(numerator);+ let denominator = i128::from(denominator);+ if denominator <= 0 {+ return 0;+ }+ let quotient = numerator / denominator;+ let remainder = numerator % denominator;+ let rounded =+ if remainder.unsigned_abs().saturating_mul(2) >= denominator.unsigned_abs() { quotient.saturating_add(remainder.signum()) } else { quotient };+ rounded.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn constants_are_mutually_consistent() {+ assert_eq!(EMU_PER_POINT * POINTS_PER_INCH, EMU_PER_INCH);+ assert_eq!(EMU_PER_TWIP * TWIPS_PER_POINT, EMU_PER_POINT);+ assert_eq!(EMU_PER_TWIP * TWIPS_PER_INCH, EMU_PER_INCH);+ assert_eq!(EMU_PER_CSS_PIXEL * CSS_PIXELS_PER_INCH, EMU_PER_INCH);+ assert_eq!(EMU_PER_CENTIMETRE * 254, EMU_PER_INCH * 100);+ assert_eq!(EMU_PER_MILLIMETRE * 254, EMU_PER_INCH * 10);+ }++ #[test]+ fn letter_page_is_exact_in_every_unit() {+ let page = Length::from_inches(8.5);+ assert_eq!(page.emu(), 7_772_400);+ assert_eq!(page.points(), 612.0);+ assert_eq!(page.twips(), 12_240);+ assert_eq!(page.centimetres(), 21.59);+ assert_eq!(page.millimetres(), 215.9);+ assert!((page.css_pixels() - 816.0).abs() < f64::EPSILON);+ }++ #[test]+ fn integer_emu_survives_point_round_trip() {+ for emu in [0_i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {+ let length = Length::from_emu(emu);+ assert_eq!(Length::from_points(length.points()).emu(), emu, "{emu}");+ }+ }++ #[test]+ fn non_finite_input_is_zero() {+ for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {+ assert_eq!(Length::from_points(value).emu(), 0, "{value}");+ assert_eq!(Length::from_inches(value).emu(), 0, "{value}");+ assert_eq!(Length::from_centimetres(value).emu(), 0, "{value}");+ assert_eq!(Length::from_css_pixels(value).emu(), 0, "{value}");+ }+ }++ #[test]+ fn enormous_input_saturates() {+ assert_eq!(Length::from_points(1.0e300).emu(), i64::MAX);+ assert_eq!(Length::from_points(-1.0e300).emu(), i64::MIN);+ assert_eq!(Length::from_emu(i64::MAX).twips(), i64::MAX / EMU_PER_TWIP);+ }++ #[test]+ fn rounding_is_half_away_from_zero() {+ assert_eq!(Length::from_emu(317).twips(), 0);+ assert_eq!(Length::from_emu(318).twips(), 1);+ assert_eq!(Length::from_emu(-317).twips(), 0);+ assert_eq!(Length::from_emu(-318).twips(), -1);+ assert_eq!(Length::from_points(0.5).twips(), 10);+ assert_eq!(Length::from_points(-0.5).twips(), -10);+ }++ #[test]+ fn twips_saturate_instead_of_overflowing() {+ assert_eq!(Length::from_twips(i64::MAX).emu(), i64::MAX);+ assert_eq!(Length::from_twips(i64::MIN).emu(), i64::MIN);+ }++ #[test]+ fn checked_arithmetic_reports_overflow() {+ let max = Length::from_emu(i64::MAX);+ let min = Length::from_emu(i64::MIN);+ assert_eq!(max.checked_add(Length::from_emu(1)), None);+ assert_eq!(min.checked_sub(Length::from_emu(1)), None);+ assert_eq!(max.saturating_add(Length::from_emu(1)).emu(), i64::MAX);+ assert_eq!(min.saturating_sub(Length::from_emu(1)).emu(), i64::MIN);+ assert_eq!(Length::from_emu(10).checked_add(Length::from_emu(5)), Some(Length::from_emu(15)));+ }++ #[test]+ fn parse_accepts_every_supported_unit() {+ assert_eq!(Length::parse("12.5pt"), Some(Length::from_points(12.5)));+ assert_eq!(Length::parse(" 2 cm "), Some(Length::from_centimetres(2.0)));+ assert_eq!(Length::parse("1in"), Some(Length::from_inches(1.0)));+ assert_eq!(Length::parse("96px"), Some(Length::from_css_pixels(96.0)));+ assert_eq!(Length::parse("1440tw"), Some(Length::from_twips(1_440)));+ assert_eq!(Length::parse("914400emu"), Some(Length::from_emu(914_400)));+ assert_eq!(Length::parse("-3MM"), Some(Length::from_millimetres(-3.0)));+ assert_eq!(Length::parse("1e3pt"), Some(Length::from_points(1_000.0)));+ }++ #[test]+ fn parse_rejects_malformed_input() {+ for text in ["", " ", "12", "pt", "12ptx", "NaNpt", "infpt", "12.5", "-", "1e3"] {+ assert_eq!(Length::parse(text), None, "{text:?}");+ }+ }+}new file mode 100644@@ -0,0 +1,43 @@+//! Property tests for the rounding and saturation guarantees in `craft-units`.++use craft_units::{EMU_PER_TWIP, Length};+use proptest::prelude::*;++/// A range wide enough to exercise real documents without pushing the `i64` saturation limits.+fn document_emu() -> impl Strategy<Value = i64> {+ -1_000_000_000_000_000_i64..=1_000_000_000_000_000_i64+}++proptest! {+ /// Converting to twips and back loses at most half a twip.+ #[test]+ fn twips_round_trip_is_within_half_a_twip(emu in document_emu()) {+ let original = Length::from_emu(emu);+ let restored = Length::from_twips(original.twips()).emu();+ let difference = i128::from(restored) - i128::from(emu);+ prop_assert!(+ difference.abs() <= i128::from(EMU_PER_TWIP / 2 + 1),+ "{emu} -> {restored} (difference {difference})"+ );+ }++ /// Rounding never inverts an order: a smaller length never rounds to more twips.+ #[test]+ fn twips_rounding_is_monotonic(left in document_emu(), right in document_emu()) {+ let (low, high) = if left <= right { (left, right) } else { (right, left) };+ prop_assert!(+ Length::from_emu(low).twips() <= Length::from_emu(high).twips(),+ "{low} <= {high}"+ );+ }++ /// Every unit conversion that lands on a whole unit returns exactly that unit.+ #[test]+ fn whole_unit_conversions_are_exact(units in -1_000_000_i64..=1_000_000_i64) {+ prop_assert_eq!(Length::from_twips(units).twips(), units);+ let points = units as f64;+ prop_assert_eq!(Length::from_points(points).points(), points);+ let inches = units as f64 / 100.0;+ prop_assert!((Length::from_inches(inches).inches() - inches).abs() < 1.0e-9);+ }+}