forked from 1b-agent/opencadstudio-upstream
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:
1 parent
4985ab42cf
commit
334960fcf3
2 files changed
+463
-73
No files matched your search
+243
-73
@@ -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::*;
|
||||
|
||||
Reference in new issue
Block a user