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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user