diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 309ac1ede..a71acae5f 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -48,6 +48,15 @@ pub async fn pick_pdf_path_owned(_stem: String) -> Option { None } +/// mm to PDF points (1 mm = 2.834645 pt). +const MM_TO_PT: f32 = 2.834645; +/// `wire.line_weight_px` is the on-screen pixel weight: mm × (96/25.4) × 2.0, +/// where the ×2 is a screen-legibility boost (see render.rs). Print wants the +/// true physical weight, so undo both the 96-dpi scaling and the boost before +/// converting to points — otherwise weights export ~2× too heavy in pixels +/// (and the old `× 0.35278` left them inconsistent with the physical mm). +const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0); + // ── Public entry point ──────────────────────────────────────────────────── /// Export `wires` to a PDF file. @@ -171,7 +180,6 @@ fn build_pdf( // Clip rectangle (mm), applied in the pre-scale coordinate space so it // matches the wires drawn under the same CTM. if let Some((cx, cy, cw, ch)) = clip { - const MM_TO_PT: f32 = 2.834645; ops.push(Op::DrawPolygon { polygon: Polygon { rings: vec![PolygonRing { @@ -204,14 +212,6 @@ fn build_pdf( } } - // mm to PDF points (1 mm = 2.834645 pt). - const MM_TO_PT: f32 = 2.834645; - // `wire.line_weight_px` is the on-screen pixel weight: mm × (96/25.4) × 2.0, - // where the ×2 is a screen-legibility boost (see render.rs). Print wants the - // true physical weight, so undo both the 96-dpi scaling and the boost before - // converting to points — otherwise weights export ~2× too heavy in pixels - // (and the old `× 0.35278` left them inconsistent with the physical mm). - const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0); // ── Hatch / wipeout fills (rendered before wires so wires draw on top, // matching paper_canvas ordering). Each `emit_hatch` sets its own @@ -327,7 +327,7 @@ fn build_pdf( // this CPU exporter can't sample, so without this pass all text — including // dimension text — is missing from the PDF (issue #385). Drawn after the // wires (on top) and under the same rotation/scale/clip CTM. - emit_text(&mut ops, wires, ox, oy); + emit_text(&mut ops, wires, ox, oy, scale, plot_style); if needs_state { ops.push(Op::RestoreGraphicsState); @@ -547,7 +547,14 @@ 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. #[cfg(not(target_arch = "wasm32"))] -fn emit_text(ops: &mut Vec, wires: &[WireModel], ox: f32, oy: f32) { +fn emit_text( + ops: &mut Vec, + wires: &[WireModel], + ox: f32, + oy: f32, + scale: f32, + plot_style: Option<&PlotStyleTable>, +) { use crate::scene::text::sdf_atlas; if wires.iter().all(|w| w.text_verts.is_empty()) { @@ -561,16 +568,34 @@ fn emit_text(ops: &mut Vec, wires: &[WireModel], ox: f32, oy: f32) { (atlas.export_table(), sdf_atlas::uv_key(atlas.solid_uv())) }; - const MM_TO_PT: f32 = 2.834645; - // Match the wire pass: screen-px weight → true physical points. - const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0); + // `Op::SetLineDashPattern` is persistent graphics state and the wire pass + // above only re-emits it on change, so whatever the last wire needed is + // still active here — without this reset a drawing whose last wire carries a + // HIDDEN/CENTER linetype prints its glyph outlines dashed. + ops.push(Op::SetLineDashPattern { + dash: LineDashPattern::default(), + }); for wire in wires { let verts = &wire.text_verts; if verts.is_empty() { continue; } - let lw_pt = (wire.line_weight_px * LW_PX_TO_PT).max(0.1); + // Mirror the wire pass: CTB colour/lineweight override by ACI, and the + // `/ scale` that keeps pen widths absolute under the scaled CTM (a Fit + // plot would otherwise render text as near-invisible hairlines). + let mut ctb_color: Option<[f32; 3]> = None; + let mut lw_override: Option = None; + if let Some(ctb) = plot_style { + if wire.aci > 0 { + ctb_color = ctb.resolve_color(wire.aci); + lw_override = ctb + .resolve_lineweight(wire.aci) + .map(|mm| (mm * MM_TO_PT).max(0.1) / scale.max(1e-6)); + } + } + let lw_pt = lw_override + .unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1) / scale.max(1e-6)); let mut gi = 0; while gi + 6 <= verts.len() { @@ -581,8 +606,12 @@ fn emit_text(ops: &mut Vec, wires: &[WireModel], ox: f32, oy: f32) { if a < 0.01 { continue; } - let [r, g, b] = - adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]]); + // A CTB colour override wins over the white-sheet adaptation, exactly + // as in the wire pass — else a monochrome.ctb plot plots the lines + // black and leaves the text on its screen colour. + let [r, g, b] = ctb_color.unwrap_or_else(|| { + adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]]) + }); // Quad corners in world XY: verts run [bl, br, tr, bl, tr, tl]. let bl = glyph_world_xy(&quad[0]); @@ -629,11 +658,14 @@ fn emit_text(ops: &mut Vec, wires: &[WireModel], ox: f32, oy: f32) { }); } } else { - // Stroke (LFF pen) font or hollow glyph: polylines. + // Stroke (LFF pen) font or hollow glyph: polylines. Bold bakes + // at a 1.7× pen over the same centrelines, so widen to match or + // a bold run prints at regular weight. ops.push(Op::SetOutlineColor { col: Color::Rgb(Rgb { r, g, b, icc_profile: None }), }); - ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) }); + let pen = if ge.bold { lw_pt * 1.7 } else { lw_pt }; + ops.push(Op::SetOutlineThickness { pt: Pt(pen) }); for stroke in &ge.strokes { if stroke.len() < 2 { continue; @@ -723,23 +755,23 @@ mod tests { // Regression for #385: SDF text (`text_verts`) must be re-emitted as vector // draw ops. Before the fix the exporter ignored `text_verts` entirely, so a // text-only wire produced no glyph geometry — dimensions/text vanished. + // + // The box is derived from the run's own parameters (origin + the 10.0 height + // `text_wire` lays out at) rather than from the quads the mapping reads, so + // it pins placement/scale independently: a wrong origin, a dropped `anno`, a + // px-vs-mm mixup or an ox/oy misuse all move the ink out of it. + // + // It does NOT catch a mirrored corner assignment (bl <-> tl): the atlas plane + // rect is the ink bbox plus a symmetric SDF spread, so a mirror maps the ink + // box onto itself and no box-based assert can see it. The vertex order that + // relies on is pinned by `text_gpu`'s own tests instead. #[test] - fn sdf_text_emits_vector_ops_within_glyph_bounds() { + fn sdf_text_emits_vector_ops_at_the_run_position() { let origin = [100.0, 50.0, 0.0]; let wire = text_wire("AB", origin); - // World bbox of the glyph quads — every emitted point must land inside. - let (mut nx, mut ny, mut xx, mut xy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); - for v in &wire.text_verts { - let (x, y) = (v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]); - nx = nx.min(x); - xx = xx.max(x); - ny = ny.min(y); - xy = xy.max(y); - } - let mut ops: Vec = Vec::new(); - emit_text(&mut ops, std::slice::from_ref(&wire), 0.0, 0.0); + emit_text(&mut ops, std::slice::from_ref(&wire), 0.0, 0.0, 1.0, None); let lines = ops .iter() @@ -747,20 +779,35 @@ mod tests { .count(); assert!(lines > 0, "stroke text emitted no polylines (ops: {})", ops.len()); - // Mapped points (Pt = mm × 2.834645, ox/oy = 0) sit within the bbox. - const MM_TO_PT: f32 = 2.834645; - let eps = 1.0; // mm slack for the SDF plane spread past the ink. + // "AB" at height 10 from (100, 50) inks a box starting at the origin, at + // most 2 glyphs wide and one cap-height tall. `eps` covers the SDF plane + // spread past the ink. + let eps = 1.0_f32; + let (x0, y0) = (origin[0] as f32, origin[1] as f32); + let (x1, y1) = (x0 + 2.0 * 10.0 + eps, y0 + 10.0 + eps); + let (mut gx0, mut gy0, mut gx1, mut gy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); for o in &ops { if let Op::DrawLine { line } = o { for lp in &line.points { + // Pt = mm × MM_TO_PT, and ox/oy are 0 here. let (x, y) = (lp.p.x.0 / MM_TO_PT, lp.p.y.0 / MM_TO_PT); assert!( - x >= nx - eps && x <= xx + eps && y >= ny - eps && y <= xy + eps, - "glyph point ({x},{y}) outside bbox [{nx},{ny},{xx},{xy}]" + x >= x0 - eps && x <= x1 && y >= y0 - eps && y <= y1, + "glyph point ({x},{y}) outside the run's world box \ + [{x0},{y0}]..[{x1},{y1}] — mis-placed or mis-scaled mapping?" ); + gx0 = gx0.min(x); + gy0 = gy0.min(y); + gx1 = gx1.max(x); + gy1 = gy1.max(y); } } } + // …and it must actually span the run, not collapse into a corner. + assert!( + gx1 - gx0 > 2.0 && gy1 - gy0 > 2.0, + "glyph ink collapsed: [{gx0},{gy0}]..[{gx1},{gy1}]" + ); } // End-to-end: a page whose only content is SDF text produces a larger PDF diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 083904cd5..fe8c7539e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -4426,6 +4426,11 @@ impl Scene { mix(c.to_bits() as u64); } mix(avp.map(|h| h.value()).unwrap_or(0)); + // SDF glyph quads bake the atlas UV of each tile, so a growth or + // a re-bake (which rescale / rewind every UV) makes memoized text + // address the wrong tile — garbage on screen, and a silent miss in + // the PDF export's glyph lookup (#385 under #347's conditions). + mix(crate::scene::text::sdf_atlas::generation()); g }; let (memo_cell, guard_cell) = if resident { diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index e0b42de93..09b641ae0 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -315,7 +315,8 @@ impl WireModel { /// glyph quads live in `text_verts` (absolute-world double-single) — so a text /// ghost (MOVE / COPY / ROTATE / SCALE / STRETCH / MIRROR preview) must carry /// these along or the dragged text renders frozen at its source (issue #316). -fn map_text_verts( +/// Paper-space viewport projection uses it for the same reason (issue #385). +pub(crate) fn map_text_verts( verts: &[crate::scene::pipeline::text_gpu::TextVertex], f: impl Fn(f64, f64, f64) -> (f64, f64, f64), ) -> Vec { diff --git a/src/scene/project.rs b/src/scene/project.rs index d60e56d04..ceb8afb56 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -179,6 +179,33 @@ impl Scene { }) .collect(); + // SDF glyph quads ride `text_verts` in absolute WCS, so they need + // the same projection the points get — a bare clone leaves the + // text at model (UTM) coordinates while its own dimension lines + // move to the sheet, putting the glyphs kilometres off the page + // (issue #385 for layout plots). Cull per glyph on the quad's + // centroid: the text analogue of `clip_polyline_to_rect`, since a + // glyph can't be split at the viewport border. + let mut projected_text = if wire.text_verts.is_empty() { + Vec::new() + } else { + model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| { + let p = proj_abs(x, y, z); + (p[0] as f64, p[1] as f64, p[2] as f64) + }) + .chunks_exact(6) + .filter(|quad| { + let (sx, sy) = quad.iter().fold((0.0f32, 0.0f32), |(ax, ay), v| { + (ax + v.pos[0] + v.pos_low[0], ay + v.pos[1] + v.pos_low[1]) + }); + let (cx, cy) = (sx / 6.0, sy / 6.0); + cx.is_finite() && cy.is_finite() && in_vp(cx, cy) + }) + .flatten() + .copied() + .collect::>() + }; + // Fast AABB pre-reject. let any_near = projected_pts.iter().any(|&[x, y, _]| { x.is_finite() @@ -202,13 +229,16 @@ impl Scene { ); let aabb_hits = max_x >= vp_x0 && min_x <= vp_x1 && max_y >= vp_y0 && min_y <= vp_y1; - if !any_near && !aabb_hits { + // A text-only wire (TEXT / MTEXT carry no stroke `points` since + // text went SDF-only) has nothing to pre-reject on, so gate both + // rejections on the glyphs too or it never reaches the sheet. + if !any_near && !aabb_hits && projected_text.is_empty() { continue; } let clipped = clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz); - if clipped.is_empty() { + if clipped.is_empty() && projected_text.is_empty() { continue; } @@ -226,6 +256,17 @@ impl Scene { pmxx = pmxx.max(x); pmxy = pmxy.max(y); } + // Glyphs count towards the paper AABB as well — a text-only wire + // would otherwise stay UNBOUNDED and never pick. + for v in &projected_text { + let (x, y) = (v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]); + if x.is_finite() && y.is_finite() { + pmnx = pmnx.min(x); + pmny = pmny.min(y); + pmxx = pmxx.max(x); + pmxy = pmxy.max(y); + } + } // Project snap points + key vertices into the same paper frame, // keeping only those inside the viewport rect, so endpoint / @@ -252,8 +293,16 @@ impl Scene { let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color); let [r, g, b, a] = adapted; + // Glyphs carry their own per-vertex colour, so dim them through + // the same adapt + 0.80/0.85 the wire colour below gets, or the + // text reads brighter than its own dimension lines. + for v in &mut projected_text { + let [tr, tg, tb, ta] = view::render::adapt_to_bg(v.color, self.paper_bg_color); + v.color = [tr * 0.80, tg * 0.80, tb * 0.80, ta * 0.85]; + } let mut out = wire.clone(); out.points = clipped; + out.text_verts = projected_text; // Paper coordinates are small sheet units — no relative-to-eye // residual is needed, and keeping the model wire's points_low // here would add a model-scale offset to the paper points. diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index e388395eb..0e967b50b 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -42,6 +42,25 @@ pub fn text_atlas() -> &'static Mutex { ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(2048, 2048))) } +/// Bumped whenever already-baked entries' UVs stop addressing their tile: +/// `grow_height` rescales every entry's V and `reset` rewinds the packer, but +/// glyph quads built earlier keep the UVs they were laid out with. Those stale +/// quads sample the wrong tile on screen and miss the export table entirely +/// (silently dropping text from the PDF — issue #385 all over again, under +/// issue #347's grow-the-atlas conditions). The tessellation memo guard folds +/// this in, so a growth re-lays-out the text instead of leaving stale quads. +static ATLAS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Current atlas UV generation — see [`ATLAS_GENERATION`]. Anything that caches +/// glyph quads must treat a change here as "re-lay-out the text". +pub fn generation() -> u64 { + ATLAS_GENERATION.load(std::sync::atomic::Ordering::Relaxed) +} + +fn bump_generation() { + ATLAS_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + /// TEXTFILL system variable — `true` fills TrueType glyphs (default), `false` /// draws them hollow (outline only). Global, like the AutoCAD system variable. static TEXTFILL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); @@ -127,6 +146,8 @@ pub struct GlyphExport { /// filled TrueType glyph — the exporter fills these and ignores `strokes`; /// empty means "stroke `strokes`" (LFF pen font, or a hollow TTF glyph). pub fill_tris: Vec<[f32; 2]>, + /// Baked with the 1.7× bold pen; the exporter widens its own pen to match. + pub bold: bool, } /// Pack a glyph quad's `uv_min` corner into a stable hash key for the export @@ -229,6 +250,8 @@ impl GlyphAtlas { /// next use (e.g. after TEXTFILL toggles between fill and outline). pub fn reset(&mut self) { *self = GlyphAtlas::new(self.width, self.height); + // The packer rewound, so every previously handed-out UV is now stale. + bump_generation(); } /// Look up a glyph's atlas placement, baking + packing it on first use. @@ -260,23 +283,39 @@ impl GlyphAtlas { /// glyph quads, look each quad's `uv_min` up here, and re-emit the glyph as /// vector strokes / fills instead of sampling the SDF texture. Re-resolves /// each entry's outline through `Face` (the same source `get_or_insert` - /// baked from), so it reflects the current TEXTFILL mode. + /// baked from), gated on TEXTFILL so a hollow-on-screen glyph exports hollow. pub fn export_table(&self) -> HashMap { + let fill_on = textfill(); let mut out = HashMap::with_capacity(self.entries.len()); + // `Face::resolve` reparses a font, so resolve once per family rather than + // once per baked glyph — a CJK drawing holds thousands of entries over a + // handful of families, and the atlas mutex is held for this whole walk. + let mut faces: HashMap<&str, Face> = HashMap::new(); for ((family, ch, bold), entry) in &self.entries { let Some(entry) = entry else { continue }; - let face = Face::resolve(family); + let face = faces + .entry(family.as_str()) + .or_insert_with(|| Face::resolve(family)); let Some(g) = face.glyph(*ch) else { continue }; - // Bold LFF glyphs bake with a wider pen but the same centrelines; - // the exporter widens the pen itself, so the geometry is shared. - let _ = bold; out.insert( uv_key(entry.uv_min), GlyphExport { plane_min: entry.plane_min, plane_max: entry.plane_max, strokes: g.strokes.clone(), - fill_tris: g.fill_tris.clone(), + // `bake_glyph` gates the texture fill on TEXTFILL the same way + // (`textfill() && !g.fill_tris.is_empty()`); `Face::glyph` does + // not, so without this the export ignores TEXTFILL 0 and prints + // solid glyphs where the screen shows outlines. + fill_tris: if fill_on { + g.fill_tris.clone() + } else { + Vec::new() + }, + // Bold stroke glyphs bake at a 1.7× pen (`get_or_insert`) but + // share the centrelines, so carry the flag and let the exporter + // widen its own pen by the same factor. + bold: *bold, }, ); } @@ -304,6 +343,8 @@ impl GlyphAtlas { self.solid_uv[1] *= sy; self.height = new_h; self.dirty = true; + // Every already-emitted quad's V now addresses the wrong tile. + bump_generation(); true } diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index f7a14efbf..c47f186c5 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -2,9 +2,12 @@ // export. From v0.8.2 text renders on-screen only as GPU SDF glyph quads // (`WireModel::text_verts`); the CPU PDF exporter used to draw only wire // stroke `points`, so all text — standalone TEXT/MTEXT and dimension text — -// vanished from exported PDFs (present in v0.7.6). This drives the real -// entity -> scene.entity_wires() -> export_pdf path and asserts text is -// carried to the exporter and lands in the file. +// vanished from exported PDFs (present in v0.7.6). +// +// The op-level placement checks live in `pdf_export`'s own unit tests, which can +// see `emit_text`'s ops. What only an integration test can cover is that the +// real entity -> scene.entity_wires() -> export_pdf path carries text at all, +// per entity kind — so that is what this asserts. use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::types::Vector3; use acadrust::EntityType; @@ -15,35 +18,59 @@ use OpenCADStudio::scene::Scene; fn text_and_dim_reach_pdf_export() { let mut scene = Scene::new(); - // A standalone TEXT entity. let t = Text::with_value("HELLO", Vector3::new(10.0, 10.0, 0.0)).with_height(5.0); - scene.add_entity(EntityType::Text(t)); + let text_h = scene.add_entity(EntityType::Text(t)); - // A linear dimension — its measurement value is synthesized as SDF text - // too, so it exercises the dimension-text path the reporters called out. - let dim = DimensionLinear::new( - Vector3::new(0.0, 0.0, 0.0), - Vector3::new(40.0, 0.0, 0.0), - ); - scene.add_entity(EntityType::Dimension(Dimension::Linear(dim))); + // A linear dimension — its measurement value is synthesized as SDF text too, + // and dimension text is what #385 was actually filed about. + let dim = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(40.0, 0.0, 0.0)); + let dim_h = scene.add_entity(EntityType::Dimension(Dimension::Linear(dim))); let wires = scene.entity_wires(); - // The regression symptom was that text produced no exportable geometry. - // It must now ride the export wire set as SDF glyph quads. - let text_wires = wires.iter().filter(|w| !w.text_verts.is_empty()).count(); + // Assert per entity, not "some wire somewhere has text": both asserts below + // would otherwise be satisfied by the TEXT alone, leaving a dimension-text + // regression — the actual reported bug — green. + // Tessellation names each wire after its entity handle (see the + // `w.name.parse::()` lookup in the render pipeline). + let has_text = |h: acadrust::types::Handle| { + let want = h.value().to_string(); + wires + .iter() + .any(|w| w.name == want && !w.text_verts.is_empty()) + }; assert!( - text_wires > 0, - "no text_verts on the export wire set — text/dim text would be missing from the PDF" + has_text(text_h), + "TEXT carries no glyph quads to the exporter" + ); + assert!( + has_text(dim_h), + "DIMENSION carries no glyph quads to the exporter — dim text would be \ + missing from the PDF (#385)" ); - // End-to-end: exporting with the text present must produce a valid PDF that - // is larger than the same wire set with the text stripped — i.e. the glyph - // geometry actually reaches the file. - let dir = std::env::temp_dir(); - let p_text = dir.join("ocs_385_with_text.pdf"); - export_pdf(&wires, &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, &p_text, None) - .expect("export with text"); + // End-to-end: the same wire set with and without text. A private temp dir + // keeps concurrent runs (and other users on a shared build host) from + // colliding on a fixed name in the shared temp root. + let dir = std::env::temp_dir().join(format!("ocs385-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + + let p_text = dir.join("with_text.pdf"); + export_pdf( + &wires, + &[], + &[], + 210.0, + 297.0, + 0.0, + 0.0, + 0, + 1.0, + None, + &p_text, + None, + ) + .expect("export with text"); let with_text = std::fs::read(&p_text).expect("read pdf"); assert!(with_text.starts_with(b"%PDF"), "not a PDF"); @@ -55,9 +82,22 @@ fn text_and_dim_reach_pdf_export() { w }) .collect(); - let p_bare = dir.join("ocs_385_no_text.pdf"); - export_pdf(&stripped, &[], &[], 210.0, 297.0, 0.0, 0.0, 0, 1.0, None, &p_bare, None) - .expect("export without text"); + let p_bare = dir.join("no_text.pdf"); + export_pdf( + &stripped, + &[], + &[], + 210.0, + 297.0, + 0.0, + 0.0, + 0, + 1.0, + None, + &p_bare, + None, + ) + .expect("export without text"); let no_text = std::fs::read(&p_bare).expect("read pdf"); assert!( @@ -66,4 +106,6 @@ fn text_and_dim_reach_pdf_export() { with_text.len(), no_text.len() ); + + let _ = std::fs::remove_dir_all(&dir); }