Files
OneCAD-Draft/tests/pdf_size_repro.rs
T
Afonsop d135b15ab6 fix: harden the issue-fix wave after full adversarial review
Five reviewers (PDF, snap, commands, otrack/help, printing) covered the
eight issue-fix commits; every blocker and major is fixed here.

Printing (#1261 follow-up):
- printto params now quote the printer name and pad the empty driver/
  port slots handlers substitute ("Microsoft Print to PDF" was split
  into three arguments and the job silently lost).
- Level-2 enumeration also merges when the level-4 list omits the
  configured default, not only when it is empty; a level-2 error no
  longer discards the default-printer merge.
- The printer list loads in a background task instead of blocking the
  UI thread (level-2 queries can wait out RPC timeouts).
- CTB registration: collisions keep the existing file but now say so,
  and only container-parsed tables are registered (the permissive
  zlib heuristic stays session-only so junk cannot persist).

Snapping (#1030/#1319 follow-up):
- Edge-wire key_vertices gain NaN separators: junctions between
  unrelated mesh edges no longer fabricate phantom midpoints.
- The wire cap counts segments (not separator points) and decimates
  instead of dropping, so dense meshes keep partial endpoint coverage
  instead of losing snap entirely.
- f64 endpoint recovery is a one-shot per-wire map instead of an
  O(points x geoms) rescan on every mouse move.

Commands (#1082 class): the same typed-value-on-web bug also hit
CYLINDER/BOX/WEDGE/CONE/PYRAMID heights, HELIX final height (wrong
value, not just dropped), and SWEEP reference/new lengths — all now
commit as text at exactly those steps, each pinned by a regression
test that fails without the override (plus the missing native-route
twin for EXTRUDE).

PDF (#702 follow-up): heavy CTB pens bypass the Form XObject (their
half-width exceeds the /BBox SDF margin and was flat-chopped), CTM
scale entries round to 4 significant digits (0.01 quantized small
text by ~1%), degenerate quads skip emission, the atlas snapshot is
taken once per export instead of per text entity, and the rounding
comments no longer conflate page points with glyph-plane units.

OTRACK (#1313 follow-up): the guide's polar quantization diverges
from the snapper's drafting-gated polar during window-corner drags —
documented; an orphaned grid-step doc block is reunited with its
function.
2026-09-17 18:42:15 +01:00

298 lines
11 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::io::plot_style::{LW_TABLE, PlotStyleTable};
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)
);
}
/// Heavy-pen (CTB lineweight override) leg: an absolute pen is arbitrary pt and
/// can exceed the glyph Form XObject /BBox's 1.5-unit SDF spread margin
/// (2.5 mm text + 1.0 mm pen → 1.79-unit half-width), squaring round caps off
/// at the tile edge. Heavy-pen entities must bypass the Forms and stroke
/// directly in page space, while the default-pen export of the same page keeps
/// its Form replay (and size class).
#[test]
fn heavy_pen_text_bypasses_forms_and_light_pen_sizes_hold() {
let heavy_ctb = {
let mut ctb = PlotStyleTable::identity("heavy-pen.ctb");
let one_mm = LW_TABLE.iter().position(|&mm| mm == 1.0).unwrap() as u8;
let entry = &mut ctb.aci_entries[1];
entry.lineweight = one_mm;
entry.color = Some([0, 0, 0]);
ctb
};
// The same 200-entity stroke-text page as the bounded leg above, with the
// wires on ACI 1 so the CTB's 1.0 mm pen applies.
let wires: Vec<PlotWire> = (0..200)
.map(|i| {
let x = 10.0 + (i % 5) as f64 * 40.0;
let y = 10.0 + (i / 5) as f64 * 7.0;
let mut wire = text_wire(
"txt",
"DIMENSION TEXT 12345.6789 - TEXT ANNOTATION LINE",
[x, y, 0.0],
2.5,
);
wire.wire.aci = 1;
wire
})
.collect();
let glyph_quads: usize = wires.iter().map(|w| w.wire.text_verts.len() / 6).sum();
let page = a4_page(wires, Vec::new());
let heavy =
render_pdf_bytes(std::slice::from_ref(&page), Some(&heavy_ctb)).expect("heavy export");
println!(
"heavy-pen (1.0 mm CTB) stroke-text 200-entity A4 page: {} bytes for {} glyph quads",
heavy.len(),
glyph_quads
);
assert!(heavy.starts_with(b"%PDF"), "not a PDF");
assert!(
heavy.len() > 100_000,
"heavy-pen text page suspiciously small: {}",
heavy.len()
);
// The bypass engaged: zero glyph Form `/Do` replays in the heavy render.
// (Page content streams are uncompressed, so the operator is greppable.)
let do_count = |bytes: &[u8]| bytes.windows(3).filter(|w| w == b" Do").count();
assert_eq!(
do_count(&heavy),
0,
"heavy-pen glyphs still replay clipped Form XObjects"
);
// Light-pen regression fence: the same page without a plot style keeps
// every glyph on the Form path (one `/Do` per quad) and its size class.
let light = render_pdf_bytes(std::slice::from_ref(&page), None).expect("light export");
println!(
"same page without CTB: {} bytes ({} Form /Do replays)",
light.len(),
do_count(&light)
);
assert!(
do_count(&light) >= glyph_quads,
"Form glyph replay vanished from the light path"
);
assert!(
light.len() < 2_000_000,
"light-pen text page regressed to {} bytes",
light.len()
);
}
/// 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());
}