From e04e559da359852a17c8fa55d42764d985262b98 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:25:50 +0200 Subject: [PATCH 1/3] PDF: writer cleanup, q nesting limit, image dedupe, style only on change (#816) Direct object writing, image dedupe, per-page style tracking, 28-level q limit, explicit joins, /ProcSet dropped. See the PR description. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 +- crates/pdf/src/lib.rs | 404 +++++++++++++++++------ crates/pdf/src/tests.rs | 530 ++++++++++++++++++++++++++++++ crates/pdf/src/tests/reference.rs | 349 ++++++++++++++++++++ 4 files changed, 1184 insertions(+), 103 deletions(-) create mode 100644 crates/pdf/src/tests/reference.rs diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..74755ce60 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (PDF writer cleanup, #816) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF writer: objects are written straight to the output, identical pictures are stored once, style operators are emitted only when they change, `q` nesting is limited to 28 and polyline joins no longer leak into later strokes (#816) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: PDF writer cleanup (#816) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df115..4af8810d4 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -13,7 +13,10 @@ mod metrics; +use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; use std::fmt::Write as _; +use std::hash::{Hash, Hasher}; /// RGB colour, 0–255 per channel. pub type Rgb = [u8; 3]; @@ -221,16 +224,75 @@ fn n(v: f32) -> String { } } -fn rgb_op(c: Rgb, stroke: bool) -> String { - let f = |v: u8| n(v as f32 / 255.0); - format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" }) +/// Appends `v / 255` with at most three decimals, exactly what `n(v as f32 / 255.0)` gives, but +/// without a float round trip or an allocation per channel. +fn push_unit(s: &mut String, v: u8) { + let m = (u32::from(v) * 1000 + 127) / 255; + match m { + 0 => s.push('0'), + 1000 => s.push('1'), + _ => { + let _ = write!(s, "0.{m:03}"); + while s.ends_with('0') { + s.pop(); + } + } + } +} + +/// Appends a colour operator line without the trailing newline: `r g b rg` (fill) or `r g b RG`. +fn push_rgb(s: &mut String, c: Rgb, stroke: bool) { + for v in c { + push_unit(s, v); + s.push(' '); + } + s.push_str(if stroke { "RG" } else { "rg" }); +} + +/// The dash array contents (`3 2`) of a dash pattern; empty for solid. +fn dash_string(dash: &[f32]) -> String { + let d: Vec = dash.iter().take(8).map(|v| n(v.max(0.0))).collect(); + d.join(" ") } struct ImageData { width: u32, height: u32, rgb: Vec, + /// `None` when every pixel is fully opaque (no soft mask is written). alpha: Option>, + /// Hash of all of the above, to find duplicates quickly; equality still compares the bytes. + hash: u64, +} + +impl ImageData { + fn new(width: u32, height: u32, rgb: Vec, alpha: Option>) -> ImageData { + let mut h = DefaultHasher::new(); + width.hash(&mut h); + height.hash(&mut h); + rgb.hash(&mut h); + alpha.hash(&mut h); + ImageData { width, height, rgb, alpha, hash: h.finish() } + } + + /// Same pixels (a hash match alone is never trusted). + fn same(&self, o: &ImageData) -> bool { + self.hash == o.hash && self.width == o.width && self.height == o.height && self.rgb == o.rgb && self.alpha == o.alpha + } +} + +/// The part of the graphics state the writer tracks so that a style operator is only emitted +/// when it changes. `None` = not set yet on this page (or since the last restore that undid it). +#[derive(Clone, Debug, Default, PartialEq)] +struct Style { + fill: Option, + stroke: Option, + /// Formatted line width. + width: Option, + /// Formatted dash array contents. + dash: Option, + /// Line join; PDF's initial value is 0 (miter). + join: u8, } /// A page under construction. Obtain one with [`PdfDoc::add_page`]. @@ -240,7 +302,12 @@ pub struct Page { content: String, fonts: [bool; 12], images: Vec, - depth: u32, + /// Style operators already in effect at the end of `content`. + style: Style, + /// One entry per accepted [`Page::save`], restored by [`Page::restore`]. + saved: Vec