diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 903f7375e..b989dd0c2 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -503,9 +503,18 @@ pub fn render_pdf_bytes( // Borrowing all pages keeps their pixel Arcs alive until this cache is dropped. // Allocation addresses cannot be reused by another source during this export. let mut image_resources = std::collections::HashMap::new(); + // Glyph Form XObjects keyed by the atlas tile key — shared across pages so + // multi-layout exports bake each unique glyph once. + let mut glyph_forms = std::collections::HashMap::new(); for (index, page) in pages.iter().enumerate() { - append_pdf_page(&mut doc, &mut image_resources, page, plot_style) - .map_err(|error| format!("Page {}: {error}", index + 1))?; + append_pdf_page( + &mut doc, + &mut image_resources, + &mut glyph_forms, + page, + plot_style, + ) + .map_err(|error| format!("Page {}: {error}", index + 1))?; } let mut warnings = Vec::new(); Ok(doc.save(&PdfSaveOptions::default(), &mut warnings)) @@ -514,6 +523,7 @@ pub fn render_pdf_bytes( fn append_pdf_page( doc: &mut PdfDocument, image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + glyph_forms: &mut std::collections::HashMap, page: &PdfPageInput, fallback_plot_style: Option<&PlotStyleTable>, ) -> Result<(), String> { @@ -746,6 +756,8 @@ fn append_pdf_page( } DrawItem::Text(wire) => { emit_text( + doc, + glyph_forms, &mut ops, std::slice::from_ref(&wire.wire), ox, @@ -1538,6 +1550,119 @@ fn emit_hatch( // ── Text (SDF glyph quads → vector strokes / fills) ──────────────────────── +/// Format a coordinate as the shortest PDF real with 0.01 resolution +/// (`3`, `3.5`, `-0.25`). 0.01 pt ≈ 3.5 µm on paper — visually lossless, but +/// it keeps path operands half the size of printpdf's default +/// shortest-round-trip f32 text (`41.5445 28.712158` → `41.54 28.71`), which +/// dominates text-heavy vector pages (#702). +fn pdf_real(v: f32) -> String { + let rounded = (v * 100.0).round() / 100.0; + if rounded == rounded.trunc() && rounded.abs() < 1e15 { + return format!("{}", rounded as i64); + } + let mut s = format!("{rounded:.2}"); + if s.ends_with('0') { + s.pop(); + if s.ends_with('.') { + s.pop(); + } + } + s +} + +/// Serialize one glyph's vector geometry as a compact PDF content stream in +/// glyph-plane units (the 9-unit space the atlas tile spans). +/// +/// Filled TrueType glyphs paint their outline contours as one multi-subpath +/// nonzero fill — the exact region the tessellated `fill_tris` cover, since +/// both derive from the same contours under the same winding rule — at a +/// fraction of the operator count. Stroke (LFF/SHX) glyphs keep one subpath +/// per pen stroke and a single `S`; colours and pen width are inherited from +/// the invoking content stream, so they stay per-instance. +fn glyph_form_bytes(ge: &crate::scene::text::sdf_atlas::GlyphExport) -> Vec { + use std::fmt::Write as _; + let mut s = String::new(); + let point = |s: &mut String, p: [f32; 2]| { + let _ = write!(s, "{} {} ", pdf_real(p[0]), pdf_real(p[1])); + }; + if !ge.fill_tris.is_empty() { + let contours: Vec<&Vec<[f32; 2]>> = ge.strokes.iter().filter(|c| c.len() >= 2).collect(); + if contours.is_empty() { + // No recovered contours (unexpected for a filled glyph): fall back + // to the triangulation, still one shared paint op at the end. + for tri in ge.fill_tris.chunks_exact(3) { + point(&mut s, tri[0]); + s.push_str("m\n"); + point(&mut s, tri[1]); + s.push_str("l\n"); + point(&mut s, tri[2]); + s.push_str("l\nh\n"); + } + } else { + for contour in contours { + point(&mut s, contour[0]); + s.push_str("m\n"); + for &p in &contour[1..] { + point(&mut s, p); + s.push_str("l\n"); + } + s.push_str("h\n"); + } + } + s.push_str("f\n"); + } else { + for stroke in &ge.strokes { + if stroke.len() < 2 { + continue; + } + point(&mut s, stroke[0]); + s.push_str("m\n"); + for &p in &stroke[1..] { + point(&mut s, p); + s.push_str("l\n"); + } + } + s.push_str("S\n"); + } + s.into_bytes() +} + +/// Wrap a glyph's path bytes in a Form XObject so every occurrence of the +/// character replays one `/Do` instead of re-emitting its whole outline +/// (#702: a 1-page Vietnamese drawing exploded to ~128 MB that way). +/// +/// Built through `XObject::External` because printpdf's `FormXObject` +/// serializer omits the spec-required `/BBox`; the external dict gives full +/// control. `/BBox` is the glyph's atlas plane rect, which by construction +/// contains all ink (it is the ink box plus the SDF spread margin). +fn glyph_form_xobject(ge: &crate::scene::text::sdf_atlas::GlyphExport) -> printpdf::XObject { + let real = |v: f32| printpdf::DictItem::Real(pdf_real(v).parse().unwrap_or(v)); + let mut dict = std::collections::BTreeMap::new(); + dict.insert("Type".into(), printpdf::DictItem::Name(b"XObject".to_vec())); + dict.insert("Subtype".into(), printpdf::DictItem::Name(b"Form".to_vec())); + dict.insert("FormType".into(), printpdf::DictItem::Int(1)); + dict.insert( + "BBox".into(), + printpdf::DictItem::Array(vec![ + real(ge.plane_min[0]), + real(ge.plane_min[1]), + real(ge.plane_max[0]), + real(ge.plane_max[1]), + ]), + ); + printpdf::XObject::External(printpdf::ExternalXObject { + stream: printpdf::ExternalStream { + dict, + content: glyph_form_bytes(ge), + compress: true, + }, + width: None, + height: None, + dpi: None, + }) +} + + /// Absolute world XY of a glyph vertex (double-single high + low parts folded). /// /// The fold must happen in f64: the pair exists because the absolute coordinate @@ -1574,6 +1699,8 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] { /// plane rect — so a stroke (LFF) font emits polylines and a filled TrueType /// glyph emits filled triangles, exactly where the SDF quad sits. fn emit_text( + doc: &mut PdfDocument, + glyph_forms: &mut std::collections::HashMap, ops: &mut Vec, wires: &[WireModel], ox: f64, @@ -1603,6 +1730,12 @@ fn emit_text( dash: LineDashPattern::default(), }); + // Glyph colour changes only: with glyphs replayed from cached Form + // XObjects a uniform text run costs one colour op total, not one per + // character. Pen width cannot dedupe this way — see emit below. + let mut last_fill: Option<[f32; 3]> = None; + let mut last_outline: Option<[f32; 3]> = None; + for wire in wires { let verts = &wire.text_verts; if verts.is_empty() { @@ -1657,68 +1790,73 @@ fn emit_text( let key = sdf_atlas::uv_key([quad[5].uv[0], quad[5].uv[1]]); // Cancel the offset in f64, then narrow: the sheet-mm result is a - // small number even when the world coordinate is UTM-scale. - let point = |wx: f64, wy: f64| Point::new(Mm((wx + ox) as f32), Mm((wy + oy) as f32)); + // small number even when the world coordinate is UTM-scale. Points + // round to 0.01 pt (see `pdf_real`). + let point = |wx: f64, wy: f64| -> Point { + let pt = |mm: f64| ((mm * MM_TO_PT as f64 * 100.0).round() / 100.0) as f32; + Point { + x: Pt(pt(wx + ox)), + y: Pt(pt(wy + oy)), + } + }; if let Some(ge) = table.get(&key) { - // Affine basis of the quad: plane_min → bl, +x → br, +y → tl. - // The glyph-space maths is small and stays f32; only the lift into - // world coordinates needs f64. let (pmin, pmax) = (ge.plane_min, ge.plane_max); let (sx, sy) = (pmax[0] - pmin[0], pmax[1] - pmin[1]); if sx.abs() < 1e-9 || sy.abs() < 1e-9 { continue; } - let map = |p: [f32; 2]| -> Point { - let u = ((p[0] - pmin[0]) / sx) as f64; - let v = ((p[1] - pmin[1]) / sy) as f64; - let wx = bl[0] + u * (br[0] - bl[0]) + v * (tl[0] - bl[0]); - let wy = bl[1] + u * (br[1] - bl[1]) + v * (tl[1] - bl[1]); - point(wx, wy) - }; + // Affine basis of the quad: plane_min → bl, +x → br, +y → tl, + // resolved in f64 exactly like the previous per-point `map`. A + // glyph-plane point p lands at + // `origin + u·(p.x − pmin.x) + v·(p.y − pmin.y)`, which as a + // `cm` lets the glyph's whole path ride one cached Form XObject + // instead of being re-emitted per character occurrence (#702). + let ux = (br[0] - bl[0]) / sx as f64; + let uy = (br[1] - bl[1]) / sx as f64; + let vx = (tl[0] - bl[0]) / sy as f64; + let vy = (tl[1] - bl[1]) / sy as f64; + let origin_x = bl[0] - ux * pmin[0] as f64 - vx * pmin[1] as f64; + let origin_y = bl[1] - uy * pmin[0] as f64 - vy * pmin[1] as f64; + let m2pt = MM_TO_PT as f64; + let r2 = |v: f64| ((v * 100.0).round() / 100.0) as f32; + // Colour ops go OUTSIDE the per-glyph q/Q: the CTM must be + // scoped, but fill colour has to persist past the Q or the + // next glyph would fill with the page-default black despite + // `last_fill` claiming the colour is already set (found by + // pixel-diffing the pre/post-fix renders). + let mut stroke_pen: Option = None; if !ge.fill_tris.is_empty() { - // Filled TrueType glyph: one filled triangle per triple. - ops.push(Op::SetFillColor { - col: Color::Rgb(Rgb { - r, - g, - b, - icc_profile: None, - }), - }); - for tri in ge.fill_tris.chunks_exact(3) { - ops.push(Op::DrawPolygon { - polygon: Polygon { - rings: vec![PolygonRing { - points: tri - .iter() - .map(|&p| LinePoint { - p: map(p), - bezier: false, - }) - .collect(), - }], - mode: PaintMode::Fill, - winding_order: WindingOrder::NonZero, - }, + if last_fill.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) { + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, + }), }); + last_fill = Some([r, g, b]); } } else { - // Stroke (LFF/SHX pen) font or hollow glyph: polylines. + // Stroke (LFF/SHX pen) font or hollow glyph. // Match the SDF atlas' nominal glyph-space pen instead of // borrowing the entity lineweight: Roman Duplex and similar // multi-stroke faces rely on that band to close the narrow // gaps between parallel centrelines. An explicit CTB // lineweight still wins and stays absolute under the plot CTM. - ops.push(Op::SetOutlineColor { - col: Color::Rgb(Rgb { - r, - g, - b, - icc_profile: None, - }), - }); + if last_outline.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) { + ops.push(Op::SetOutlineColor { + col: Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, + }), + }); + last_outline = Some([r, g, b]); + } let pen = if let Some(ctb_pen) = lw_override { if ge.bold { ctb_pen * 1.7 @@ -1732,36 +1870,62 @@ fn emit_text( (2.0 * sdf_atlas::stroke_pen_half_units(ge.bold) * glyph_unit_mm * MM_TO_PT) .max(0.1) }; - ops.push(Op::SetOutlineThickness { pt: Pt(pen) }); - for stroke in &ge.strokes { - if stroke.len() < 2 { - continue; - } - ops.push(Op::DrawLine { - line: Line { - points: stroke - .iter() - .map(|&p| LinePoint { - p: map(p), - bezier: false, - }) - .collect(), - is_closed: false, - }, - }); - } + stroke_pen = Some(((pen * 100.0).round() / 100.0).max(0.1)); } + ops.push(Op::SaveGraphicsState); + ops.push(Op::SetTransformationMatrix { + matrix: printpdf::CurTransMat::Raw([ + r2(ux * m2pt), + r2(uy * m2pt), + r2(vx * m2pt), + r2(vy * m2pt), + r2((origin_x + ox) * m2pt), + r2((origin_y + oy) * m2pt), + ]), + }); + if let Some(pen) = stroke_pen { + // Line width is GEOMETRIC state: the CTM above scales a `w` + // set before it, so the pen is re-set per glyph, under its + // own CTM, pre-divided by the quad's area scale to keep the + // on-paper width identical to stroking in page units. + let det = (ux * vy - uy * vx).abs() * m2pt * m2pt; + let unit_scale = det.sqrt().max(1e-6) as f32; + ops.push(Op::SetOutlineThickness { + pt: Pt((pen / unit_scale * 100.0).round() / 100.0), + }); + } + // First occurrence of this glyph in the export bakes its path + // into a shared Form XObject; repeats replay it with `/Do`. + let id = glyph_forms + .entry(key) + .or_insert_with(|| { + let id = printpdf::XObjectId::new(); + doc.resources + .xobjects + .map + .insert(id.clone(), glyph_form_xobject(ge)); + id + }) + .clone(); + ops.push(Op::UseXobject { + id, + transform: Default::default(), + }); + ops.push(Op::RestoreGraphicsState); } else if key == solid_key { // Decoration bar (underline / overline / strike): the quad is a // solid-texel rectangle — fill it directly from its corners. - ops.push(Op::SetFillColor { - col: Color::Rgb(Rgb { - r, - g, - b, - icc_profile: None, - }), - }); + if last_fill.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) { + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, + }), + }); + last_fill = Some([r, g, b]); + } ops.push(Op::DrawPolygon { polygon: Polygon { rings: vec![PolygonRing { @@ -1782,6 +1946,12 @@ fn emit_text( } } +/// Channel-wise colour change test at the same 0.01 tolerance the wire pass +/// uses for its own state dedupe. +fn color_differs(a: [f32; 3], b: [f32; 3]) -> bool { + (a[0] - b[0]).abs() > 0.01 || (a[1] - b[1]).abs() > 0.01 || (a[2] - b[2]).abs() > 0.01 +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; diff --git a/tests/pdf_size_repro.rs b/tests/pdf_size_repro.rs new file mode 100644 index 000000000..a32652488 --- /dev/null +++ b/tests/pdf_size_repro.rs @@ -0,0 +1,220 @@ +// #702 repro / regression: a simple 1-page A4 drawing whose only heavy +// content is Unicode (Vietnamese) TEXT exploded into a ~128 MB PDF. +// +// Text rides the wire model as SDF glyph quads (`WireModel::text_verts`); +// `pdf_export::emit_text` re-emits each quad's atlas glyph as vector path +// ops, so the exported file stays sharp. This test measures what that costs +// in serialized content-stream bytes and fails if a change regresses the +// size class of a text-only page. +#![cfg(not(target_arch = "wasm32"))] + +use OpenCADStudio::io::pdf_export::{ + render_pdf_bytes, PdfPageInput, PdfPlotOptions, PlotContent, PlotWire, +}; +use OpenCADStudio::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily}; +use OpenCADStudio::scene::pipeline::text_gpu::push_glyph_vertices; +use OpenCADStudio::scene::text::glyph_quads::layout_glyph_quads; +use OpenCADStudio::scene::text::sdf_atlas; +use OpenCADStudio::scene::WireModel; + +/// Vietnamese sample with the full diacritic inventory (tonal marks, +/// vowel diacritics) — the glyphs an LFF stroke font lacks, so the atlas +/// falls back to a filled TrueType face exactly like the reporter's drawing. +const VIETNAMESE: &str = + "Tiếng Việt: trường hợp Thanh, Hòa, rẻ, mượn, subway — Nguyễn Văn An ỗ ễ ợ ử ữ ắ ằ ẳ ẵ ặ"; + +fn text_wire(font: &str, text: &str, origin: [f64; 3], height: f32) -> PlotWire { + let quads = { + let mut atlas = sdf_atlas::text_atlas().lock().unwrap(); + layout_glyph_quads(&mut atlas, height, 0.0, 1.0, 0.0, 1.0, font, false, text) + }; + assert!(!quads.is_empty(), "no glyph quads laid out for {text:?}"); + let mut verts = Vec::new(); + push_glyph_vertices(&mut verts, &quads, origin, 1.0, [1.0, 0.0, 0.0, 1.0], 0.0); + PlotWire { + wire: WireModel { + text_verts: verts, + ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + }, + draw_depth: 0.0, + } +} + +fn a4_page(wires: Vec, hatches: Vec) -> PdfPageInput { + PdfPageInput { + content: PlotContent { + wires: std::sync::Arc::new(wires), + hatches, + ..Default::default() + }, + paper_w: 210.0, + paper_h: 297.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + } +} + +/// 200 short Vietnamese annotations on one A4 sheet — a "simple" text-heavy +/// page like the one in #702. +fn vietnamese_page() -> PdfPageInput { + let wires = (0..200) + .map(|i| { + let x = 10.0 + (i % 5) as f64 * 40.0; + let y = 10.0 + (i / 5) as f64 * 7.0; + text_wire("txt", VIETNAMESE, [x, y, 0.0], 2.5) + }) + .collect(); + a4_page(wires, Vec::new()) +} + +#[test] +fn vietnamese_text_page_size_repro() { + let page = vietnamese_page(); + let glyph_quads: usize = page + .content + .wires + .iter() + .map(|w| w.wire.text_verts.len() / 6) + .sum(); + let bytes = render_pdf_bytes(&[page], None).expect("export"); + println!( + "vietnamese 200-entity A4 page: {} bytes for {} glyph quads ({:.0} bytes/glyph)", + bytes.len(), + glyph_quads, + bytes.len() as f64 / glyph_quads.max(1) as f64 + ); + assert!(bytes.starts_with(b"%PDF"), "not a PDF"); + // Regression fence: with per-glyph Form XObject reuse this page measures + // ~1.2 MB (95 bytes/glyph) where the per-character outline re-emission of + // #702 measured 51.5 MB (3,964 bytes/glyph) — 8 MB splits the two with + // headroom for platform font differences (which only change the ~65 baked + // unique-glyph forms, not the constant per-instance `q/cm/Do/Q` cost). + assert!( + bytes.len() < 8_000_000, + "Vietnamese text page regressed to {} bytes ({} bytes/glyph) — glyph \ + outlines are being re-emitted per character again (#702)", + bytes.len(), + bytes.len() / glyph_quads.max(1) + ); + if let Ok(dir) = std::env::var("OCS_PDF_DUMP") { + let path = std::path::Path::new(&dir).join("vietnamese.pdf"); + std::fs::write(&path, &bytes).expect("dump pdf"); + println!("dumped to {}", path.display()); + } +} + +/// The same glyph quads rendered with the embedded "txt" stroke font only +/// (ASCII, no system-font fallback) — deterministic on every platform, so +/// this is the leg that carries the regression bound. +#[test] +fn stroke_text_page_size_is_bounded() { + let wires = (0..200) + .map(|i| { + let x = 10.0 + (i % 5) as f64 * 40.0; + let y = 10.0 + (i / 5) as f64 * 7.0; + text_wire( + "txt", + "DIMENSION TEXT 12345.6789 - TEXT ANNOTATION LINE", + [x, y, 0.0], + 2.5, + ) + }) + .collect(); + let page = a4_page(wires, Vec::new()); + let glyph_quads: usize = page + .content + .wires + .iter() + .map(|w| w.wire.text_verts.len() / 6) + .sum(); + let bytes = render_pdf_bytes(&[page], None).expect("export"); + println!( + "stroke-text 200-entity A4 page: {} bytes for {} glyph quads ({:.0} bytes/glyph)", + bytes.len(), + glyph_quads, + bytes.len() as f64 / glyph_quads.max(1) as f64 + ); + assert!(bytes.starts_with(b"%PDF"), "not a PDF"); + // Regression bound: ~90 chars × 200 rows of vector stroke text must stay + // in the hundreds-of-KB class, not tens of MB. The bound is generous + // (~2.6x headroom over the post-fix measurement) so font/tessellation + // updates on different platforms cannot flip it, while a return of the + // full-precision-per-coordinate explosion (10x+) fails loudly. + assert!( + bytes.len() < 2_000_000, + "text-only A4 page regressed to {} bytes ({} bytes/glyph)", + bytes.len(), + bytes.len() / glyph_quads.max(1) + ); +} + +/// Pattern hatch + dense polyline comparison leg: identifies which content +/// type dominates a mixed page. ANSI31-style 45° hatch over an A4-sized rect. +#[test] +fn hatch_and_polyline_page_sizes() { + // 45° lines, 2 mm spacing → ~210 lines across the 297 mm height. + let hatch = HatchModel { + pattern_origin: None, + render_instance: None, + world_origin: [0.0, 0.0], + boundary: std::sync::Arc::new( + vec![ + [10.0, 10.0], + [200.0, 10.0], + [200.0, 287.0], + [10.0, 287.0], + [10.0, 10.0], + ], + ), + boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, + boundary_exterior: None, + boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, + pattern: HatchPattern::Pattern(vec![PatFamily { + angle_deg: 45.0, + x0: 0.0, + y0: 0.0, + dx: 2.0, + dy: 2.0, + dashes: vec![], + }]), + name: "ANSI31".to_string(), + color: [0.0, 0.0, 0.0, 1.0], + aci: 7, + line_weight_px: 1.0, + angle_offset: 0.0, + scale: 1.0, + draw_depth: 0.0, + }; + let hatch_bytes = render_pdf_bytes(&[a4_page(Vec::new(), vec![hatch])], None).expect("export"); + println!("one ANSI31 A4 hatch page: {} bytes", hatch_bytes.len()); + + // Dense survey-style polyline: 20 polylines × 2000 vertices. + let wires = (0..20) + .map(|row| { + let points = (0..2000) + .map(|i| { + [ + (10.0 + i as f64 * 0.09) as f32, + (20.0 + row as f64 * 10.0 + (i as f64 * 0.37).sin() * 4.0) as f32, + 0.0, + ] + }) + .collect(); + PlotWire { + wire: WireModel::solid("p".into(), points, WireModel::WHITE, false), + draw_depth: 0.0, + } + }) + .collect(); + let wire_bytes = render_pdf_bytes(&[a4_page(wires, Vec::new())], None).expect("export"); + println!("dense polyline page (40k verts): {} bytes", wire_bytes.len()); +}