Files
OneCAD-Draft/tests/pdf_size_repro.rs
T
Afonsop 334960fcf3 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.
2026-09-17 13:01:12 +01:00

221 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #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());
}