diff --git a/ROADMAP.md b/ROADMAP.md index a505acdf2..5ff15b422 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -200,6 +200,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) | | 2026-10-13 | Grid: resizing several selected columns or rows previews on the dragged one and applies the size to all on release; Esc cancels (#1082) | | 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained | +| 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 | Insert › Text › WordArt (`insert.wordArt`, `wordArt.set`): floating decorative text with 10 original styles, outline and shadow, in-place editing, XLSX and print (#784) | | 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) | | 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts | @@ -317,6 +318,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) | | 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries | | 2026-10-13 | trivial | Progress log: charts cached between frames (#1139) | +| 2026-10-12 | minor | Progress log: PDF writer cleanup (#816) | | 2026-10-12 | minor | Catalog 276 → 277 / 292: Insert › WordArt (#784) | | 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row | | 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 68983828f..b94aaf6a7 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -19,7 +19,10 @@ mod symbol; pub use symbol::{symbol_code, symbol_width}; +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]; @@ -322,6 +325,31 @@ fn n(v: f32) -> String { } } +/// 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" }); +} + /// True when every value is finite (NaN and infinities are rejected). fn finite(v: &[f32]) -> bool { v.iter().all(|x| x.is_finite()) @@ -348,16 +376,44 @@ fn dash_values(dash: &[f32]) -> String { d.iter().map(|v| n(*v)).collect::>().join(" ") } -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" }) -} - 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, } enum LinkTarget { @@ -378,8 +434,18 @@ pub struct Page { content: String, fonts: [bool; FONT_SLOTS], images: Vec, + /// Indexes into `images`, by content hash; a hash match is confirmed with [`ImageData::same`]. + image_index: HashMap>, + /// Pixel comparisons made while looking up duplicate pictures (test instrumentation). + #[cfg(test)] + image_comparisons: usize, + /// Style operators already in effect at the end of `content`. + style: Style, + /// One entry per accepted [`Page::save`], restored by [`Page::restore`]. + saved: Vec