perf(pdf): dedupe glyph outlines into Form XObjects, round coordinates (#702)

A text-heavy sheet re-emitted every glyph occurrence as a full page-
space outline (~4 KB per character at f32 round-trip precision), so
PRINT of a simple Vietnamese-text A4 produced ~128 MB. Each unique
glyph is now baked once into a shared compressed Form XObject keyed
by atlas tile (mirroring the image XObject cache, shared across
pages); repeats replay one cm + /Do. Glyph path points, CTM entries,
and decoration quads round to 0.01 pt (~3.5 µm, visually lossless);
run-level colors dedupe outside the per-glyph q/Q so state persists;
the stroke-font pen is re-set per glyph under its CTM, pre-divided by
the quad's area scale.

200-entity Vietnamese repro: 51.5 MB -> 1.28 MB (40x, ~99 B per
glyph occurrence); stroke-font pages 2.0x; hatch/polyline pages
byte-identical. Validated lossless by 300 dpi Ghostscript pixel diff
(residuals confined to 2 px glyph-edge phase) and pypdf structure
check. Raster quality presets and vector geometry are untouched.
This commit is contained in:
Afonsop committed 2026-09-17 13:01:12 +01:00
1 parent 4985ab42cf
commit 334960fcf3
2 files changed
+463 -73

No files matched your search

+243 -73
View File
@@ -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<u64, printpdf::XObjectId>,
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<u8> {
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<u64, printpdf::XObjectId>,
ops: &mut Vec<Op>,
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<f32> = 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::*;
+220
View File
@@ -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<PlotWire>, hatches: Vec<HatchModel>) -> 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());
}