From 52d766b48889ee7c407ca67a6c6a00650dddd3eb Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 17:28:09 +0800 Subject: [PATCH 1/6] fix(plot): scale wide polyline band width into paper units through viewports `viewport_content_wires` projects model-space wires into paper coordinates for plotting: the points and the linetype stations are multiplied by the viewport scale, but `world_width` (the band width of a wide polyline) was copied unchanged. The PDF / print exporter strokes that band at `world_width` in paper millimetres, so a 15-unit-wide bus bar seen through a 1:100 viewport came out as a ~21 mm black blob instead of 0.15 mm, and every single-line diagram sheet plotted as solid black shapes. Scale `world_width` by the viewport scale alongside the points, and keep the per-point taper widths in step (or drop them when clipping changed the vertex count, so they cannot misalign). Negative (fixed pixel) and zero (lineweight) widths are left untouched. Adds a regression test that plots a 15-unit-wide polyline through a 1:10 viewport and expects a 1.5-unit band in the projected plot wires. Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/project.rs | 16 ++++++ tests/viewport_wide_polyline_plot_width.rs | 60 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/viewport_wide_polyline_plot_width.rs diff --git a/src/scene/project.rs b/src/scene/project.rs index 4d67327ac..fa8902a94 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -321,6 +321,22 @@ impl Scene { } let mut out = wire.clone(); out.points = clipped; + // A wide polyline's band width is a geometric width in model + // units; the points were just projected into paper units, so + // the band (and any per-point taper) must follow the same + // scale or the PDF / print exporter strokes a 15-unit bus bar + // as 15 mm of paper. Negative values are fixed pixel widths + // and zero means "use the lineweight" — both stay as they are. + if out.world_width > 0.0 { + out.world_width = wire.world_width * scale; + if wire.taper_widths.len() == out.points.len() { + out.taper_widths = wire.taper_widths.iter().map(|w| w * scale).collect(); + } else { + // Clipping changed the vertex count, so the per-point + // widths no longer line up; fall back to a constant band. + out.taper_widths.clear(); + } + } if let Some(source_length) = source_length { for station in &mut clipped_stations { *station *= scale; diff --git a/tests/viewport_wide_polyline_plot_width.rs b/tests/viewport_wide_polyline_plot_width.rs new file mode 100644 index 000000000..b527ee5ea --- /dev/null +++ b/tests/viewport_wide_polyline_plot_width.rs @@ -0,0 +1,60 @@ +//! A wide polyline seen through a paper-space viewport must carry its band +//! width in PAPER units once projected for plotting. `viewport_content_wires` +//! scales the points and the linetype stations by the viewport scale but used +//! to leave `world_width` in model units, so the PDF / print exporter stroked a +//! 15-unit-wide bus bar at 15 mm of paper (a 1:100 viewport should give +//! 0.15 mm): every single-line diagram plotted as black blobs. +#![cfg(not(target_arch = "wasm32"))] + +use acadrust::entities::{LwPolyline, LwVertex, Viewport}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; +use OpenCADStudio::scene::Scene; + +#[test] +fn viewport_projection_scales_wide_polyline_band_width() { + // A fresh `Scene::new()` has no paper layout block yet; reuse the small + // fixture the image-export test plots through, which defines `Layout1`. + let mut scene = Scene::new(); + scene.document = OpenCADStudio::io::load_bytes( + "embedded-image-print.dxf", + include_bytes!("fixtures/embedded-image-print.dxf").to_vec(), + ) + .expect("fixture DXF with a Layout1"); + scene.set_current_layout("Model".into()); + + // Model space: a 15-unit-wide bus bar 100 units long. + let mut bar = LwPolyline::new(); + bar.vertices = vec![ + LwVertex::new(Vector2::new(0.0, 0.0)), + LwVertex::new(Vector2::new(100.0, 0.0)), + ]; + bar.constant_width = 15.0; + scene.add_entity(EntityType::LwPolyline(bar)); + + // Paper space: a 20 x 20 mm viewport showing 200 model units of height, + // i.e. a 1:10 view (0.1 paper units per model unit). + scene.set_current_layout("Layout1".into()); + let mut viewport = Viewport::new(); + viewport.center = Vector3::new(50.0, 50.0, 0.0); + viewport.width = 20.0; + viewport.height = 20.0; + viewport.view_height = 200.0; + viewport.view_center = Vector3::new(50.0, 0.0, 0.0); + viewport.id = 2; + viewport.status.is_on = true; + scene.add_entity(EntityType::Viewport(viewport)); + + let (_paper, model) = scene.plot_wire_groups(None); + let band = model + .iter() + .find(|w| w.world_width > 0.0) + .expect("the wide polyline must reach the plot wire set with a band width"); + + // 15 model units * 0.1 = 1.5 paper units. Before the fix this stayed 15.0. + assert!( + (band.world_width - 1.5).abs() < 1e-3, + "band width must be scaled into paper units: got {}", + band.world_width + ); +} From 0b522ceb862c1ba7ba515f64054643c8073c593f Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 18:14:26 +0800 Subject: [PATCH 2/6] fix(text): size TrueType fallback ideographs by the em box, like a big font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a text style's SHX / stroke font lacks a character, the glyph comes from a system TrueType font through `ttf_glyph::fallback_glyph`. That substitute was normalised to the Latin cap height (9 units = text height), which is right for Latin, Greek or Cyrillic stand-ins but wrong for CJK: an ideograph fills the whole em square, and 1 em is 1.3–1.5 cap heights in every common CJK font (DFKai-SB has no OS/2 cap height at all, so it fell to the 0.7 em guess). Every substituted Chinese character therefore came out ~40 % taller and wider than the big font (`chineset.shx`, `hztxt.shx`, …) the drawing was authored with, and whole lines of notes ran past their frames. A big font draws an ideograph one text height tall and one text height wide, so size full-width / ideographic fallback glyphs by their em box instead. Other scripts keep the cap-height normalisation. Real TrueType styles (`Face::Ttf`, shaped runs) are unchanged. Adds a unit test that a fallback `中` advances by about one text height and its ink fits the text height (skipped on machines without a CJK font). Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/text/ttf_glyph.rs | 80 ++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 2c76b291c..0024034b2 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -176,6 +176,42 @@ pub fn glyph(family: &str, ch: char) -> Option> { built } +/// Font-unit → 9-unit-em-box factor: the whole em square maps onto the text +/// height. This is how an SHX big font (`chineset.shx`, `hztxt.shx`, …) +/// sizes its ideographs — a CJK glyph is as tall as the text height and one +/// text height wide — so a TrueType glyph standing in for a missing big-font +/// glyph must use the same box, not the Latin cap height. Cap-height scaling +/// makes ideographs ~1.3–1.5× too big (1 em ≈ 1.3–1.5 cap heights in CJK +/// fonts), so every line of substituted Chinese text ran past its frame. +fn em_scale(face: &ttf_parser::Face) -> f32 { + CAP_UNITS / face.units_per_em().max(1) as f32 +} + +/// Ideographic / full-width characters: the ones a big font would supply and +/// that sit on an em box rather than the Latin cap height. +pub(crate) fn is_full_width(ch: char) -> bool { + matches!( + ch as u32, + 0x1100..=0x11FF // Hangul Jamo + | 0x2E80..=0x2FDF // CJK / Kangxi radicals + | 0x2FF0..=0x303F // ideographic description, CJK symbols & punctuation + | 0x3040..=0x30FF // Hiragana, Katakana + | 0x3100..=0x312F // Bopomofo + | 0x3130..=0x318F // Hangul compatibility Jamo + | 0x3190..=0x31FF // Kanbun, Bopomofo ext., CJK strokes, Katakana ext. + | 0x3200..=0x33FF // enclosed CJK, CJK compatibility + | 0x3400..=0x4DBF // CJK ext. A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xA960..=0xA97F // Hangul Jamo ext. A + | 0xAC00..=0xD7FF // Hangul syllables, Jamo ext. B + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE30..=0xFE4F // CJK compatibility forms + | 0xFF01..=0xFF60 // full-width ASCII variants + | 0xFFE0..=0xFFE6 // full-width symbols + | 0x20000..=0x3FFFF // CJK ext. B–H + ) +} + /// Font-unit → 9-unit-cap-height factor for a parsed face. Cap height comes /// from the OS/2 table; absent, we approximate it as 0.7 × units-per-em. fn cap_scale(face: &ttf_parser::Face) -> f32 { @@ -379,7 +415,16 @@ fn build_fallback(ch: char) -> Option> { let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); let font = fs.get_font(g.font_id, g.font_weight)?; let face = ttf_parser::Face::parse(font.data(), face_index).ok()?; - let k = cap_scale(&face); + // A fallback glyph stands in for a stroke / SHX font that lacks + // the character. For an ideograph that font would have been a + // big font, whose glyphs fill the text height — so size the + // substitute by its em box. Everything else keeps the cap-height + // normalisation that lines it up with the Latin stroke glyphs. + let k = if is_full_width(ch) { + em_scale(&face) + } else { + cap_scale(&face) + }; let gid = ttf_parser::GlyphId(g.glyph_id); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; let mut fl = OutlineFlattener::new(k); @@ -609,6 +654,39 @@ mod tests { assert!(!run.glyphs.is_empty()); } + #[test] + fn fallback_ideograph_fills_the_text_height_like_a_big_font() { + // An SHX big font draws an ideograph one text height tall and one + // text height wide. The TrueType stand-in must match that box (9 + // units), not the ~12–14 units the Latin cap-height normalisation + // gives a CJK em square — that overrun pushed every substituted + // Chinese line past its frame. Tolerated when the machine has no + // CJK font at all. + let Some(g) = fallback_glyph('中') else { + eprintln!("no CJK system font; skipping"); + return; + }; + assert!( + (g.advance - CAP_UNITS).abs() < 0.6, + "ideograph advance must be about one text height: got {}", + g.advance + ); + let ink_h = g + .strokes + .iter() + .flatten() + .map(|p| p[1]) + .fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y))); + assert!( + ink_h.1 - ink_h.0 <= CAP_UNITS * 1.05, + "ideograph ink must fit the text height: {:?}", + ink_h + ); + // Latin fallback keeps the cap-height convention (shares a baseline + // with the stroke glyphs around it). + assert!(!is_full_width('A') && is_full_width('中') && is_full_width(',')); + } + #[test] fn shape_run_positions_glyphs() { let fams = sysfont::families(); From ff4981ffe6a649a7edfcc63b2e12aa19bc080f7f Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 21:01:05 +0800 Subject: [PATCH 3/6] feat(text): read unifont and big-font .shx files; pair a style's big font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shx.rs` only accepted the classic `AutoCAD-86 shapes 1.x` container, so every modern Autodesk font (`txt.shx`, `simplex.shx`, `romans.shx`, … are `unifont 1.0`) and every Asian big font (`chineset.shx`, `hztxt.shx`, `bigfont.shx` — `bigfont 1.0`) was rejected and text fell back to the LFF substitutes and TrueType fallback glyphs. Drawings authored with SHX big fonts (the norm for Chinese / Japanese / Korean sheets) never showed their real glyphs. - Parse all three containers. Unifont glyphs are keyed by Unicode; big-font glyphs by their double-byte code from a sparse hash table of (code, length, offset) entries. Big fonts reference subshapes with a two-byte `7, 0, hi, lo`. - `bigfont_glyph` encodes a character with encoding_rs (Big5 / GBK / Shift_JIS / EUC-KR, most likely first for the file name) and takes the first code the font has. - `Face::WithBig` pairs a style's big font with its primary face: full-width characters come from the big font, everything else from the primary — how a STYLE with a "Big Font" behaves in AutoCAD. `resolve_text_style` resolves `big_font_file` like the primary (next to the drawing, then the user fonts folder) and rides it along in the font name. - The missing-fonts dialog lists unresolvable big fonts too. Unit tests cover a synthetic big font (Big5 lookup, cap-height scaling, no aliasing of unknown codes), a synthetic unifont, and the pairing. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 + src/entities/text_support.rs | 21 +++ src/io/font_repo.rs | 27 ++-- src/scene/text/font_face.rs | 64 ++++++++- src/scene/text/shx.rs | 270 ++++++++++++++++++++++++++++++++--- 6 files changed, 351 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4da766abd..e50a11fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "console_error_panic_hook", "cosmic-text 0.15.0", "dwg-thumbnailer", + "encoding_rs", "env_logger", "flate2", "fluent-syntax", diff --git a/Cargo.toml b/Cargo.toml index 6e78631dc..b8d1a54fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ ttf-parser = "0.25" cosmic-text = "0.15" unicode-bidi = "0.3" lyon_tessellation = "1.0.20" +# Big-font (.shx) lookups: encode a char to the font's Big5 / GBK / Shift_JIS +# / EUC-KR double-byte code. Already in the tree through other deps. +encoding_rs = "0.8" pollster = "0.4" [dev-dependencies] diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 743fe85ef..d4adac03f 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -77,6 +77,27 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT } } + // A style's "Big Font" (`chineset.shx`, `hztxt.shx`, …) supplies the + // double-byte characters. Resolve it like the primary font (next to the + // drawing, then the per-user fonts folder) and pair it with the face; an + // unresolvable big font just leaves those characters to the fallback. + if let Some(big) = style + .map(|s| s.big_font_file.trim()) + .filter(|big| !big.is_empty() && big.to_ascii_lowercase().ends_with(".shx")) + { + let base = document + .source_path + .as_deref() + .map(std::path::Path::new) + .and_then(|p| p.parent()); + let big_path = crate::io::resolve_image_file(big, base).or_else(|| { + crate::io::font_repo::local_font_file(big).map(|p| p.to_string_lossy().into_owned()) + }); + if let Some(big_path) = big_path { + font_name = Face::with_big_font(&font_name, &big_path); + } + } + ResolvedTextStyle { font_name, width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0), diff --git a/src/io/font_repo.rs b/src/io/font_repo.rs index 8df774c66..5b53e8ec7 100644 --- a/src/io/font_repo.rs +++ b/src/io/font_repo.rs @@ -70,18 +70,21 @@ pub fn missing_shx_fonts(doc: &CadDocument) -> Vec { .map(Path::new) .and_then(|p| p.parent()); for style in doc.text_styles.iter() { - let file = style.font_file.trim(); - if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") { - continue; - } - let resolved = crate::io::resolve_image_file(file, base).is_some() - || local_font_file(file).is_some(); - if resolved { - continue; - } - let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string(); - if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) { - missing.push(name); + // The primary font and the big font (Asian double-byte glyphs) are + // both plain .shx files the style depends on. + for file in [style.font_file.trim(), style.big_font_file.trim()] { + if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") { + continue; + } + let resolved = crate::io::resolve_image_file(file, base).is_some() + || local_font_file(file).is_some(); + if resolved { + continue; + } + let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string(); + if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) { + missing.push(name); + } } } missing.sort_by_key(|name| name.to_ascii_lowercase()); diff --git a/src/scene/text/font_face.rs b/src/scene/text/font_face.rs index 832e1eaf4..42d29cbdb 100644 --- a/src/scene/text/font_face.rs +++ b/src/scene/text/font_face.rs @@ -47,12 +47,37 @@ pub enum Face { /// Space advance (9-unit). word: f32, }, + /// A face paired with an Asian big font (`chineset.shx`, `hztxt.shx`, …): + /// double-byte characters come from the big font, everything else from + /// `primary` — exactly how a STYLE with a "Big Font" behaves in AutoCAD. + WithBig { + primary: Box, + /// Absolute path of the `AutoCAD-86 bigfont 1.0` file. + big: String, + }, } +/// Separator between a style's primary font name and its big-font path in +/// the single `font_name` string the text pipeline passes around (see +/// [`Face::with_big_font`]); a control character no font name contains. +pub const BIG_FONT_SEP: char = '\u{1}'; + impl Face { /// Resolve a style's font name to a concrete face. Embedded stroke fonts /// take priority; only otherwise-unknown names try the system fonts. pub fn resolve(font_name: &str) -> Face { + // `primarybig` pairs a big font with the primary face. + if let Some((primary, big)) = font_name.split_once(BIG_FONT_SEP) { + let face = Face::resolve(primary); + return if !big.is_empty() && shx::is_bigfont(big) { + Face::WithBig { + primary: Box::new(face), + big: big.to_string(), + } + } else { + face + }; + } // A resolvable on-disk .SHX font renders its real stroke glyphs; the // LFF substitutes only cover names we can't load. if font_name.to_ascii_lowercase().ends_with(".shx") @@ -87,6 +112,13 @@ impl Face { Face::Lff(lff::get_font(font_name)) } + /// The `font_name` string that pairs `primary` with the big font at + /// `big_path` for [`Face::resolve`]. Every text entry point takes a font + /// name, so the pairing rides along in that string. + pub fn with_big_font(primary: &str, big_path: &str) -> String { + format!("{primary}{BIG_FONT_SEP}{big_path}") + } + /// Look up a glyph. A stroke (LFF) font uses its own glyphs; anything it /// lacks falls back to a system TrueType font chosen by cosmic-text (filled /// outline, so covers scripts no stroke font provides). The TTF path mirrors @@ -103,6 +135,14 @@ impl Face { Face::Shx { path, .. } => shx::font_glyph(path, ch as u16) .map(GlyphRef::Owned) .or_else(|| ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned)), + Face::WithBig { primary, big } => { + if ttf_glyph::is_full_width(ch) { + if let Some(g) = shx::bigfont_glyph(big, ch) { + return Some(GlyphRef::Owned(g)); + } + } + primary.glyph(ch) + } } } @@ -114,6 +154,7 @@ impl Face { Face::Ttf { .. } => 0.0, // SHX advances already include the trailing gap by design. Face::Shx { .. } => 0.0, + Face::WithBig { primary, .. } => primary.letter_spacing(), } } @@ -122,6 +163,7 @@ impl Face { match self { Face::Lff(f) => f.word_spacing, Face::Ttf { word, .. } | Face::Shx { word, .. } => *word, + Face::WithBig { primary, .. } => primary.word_spacing(), } } @@ -132,6 +174,7 @@ impl Face { match self { Face::Ttf { family, .. } => Some(family), Face::Lff(_) | Face::Shx { .. } => None, + Face::WithBig { primary, .. } => primary.ttf_family(), } } @@ -142,6 +185,7 @@ impl Face { Face::Lff(f) => f.line_spacing, Face::Ttf { .. } => 1.0, Face::Shx { line, .. } => *line, + Face::WithBig { primary, .. } => primary.line_spacing(), } } } @@ -236,11 +280,27 @@ mod tests { // they should resolve to Ttf. If they aren't installed, Lff fallback is accepted. eprintln!("Font {} resolved to Lff (probably not installed)", test_name); } - Face::Shx { .. } => { - // A system TTF name never resolves to an SHX shape font. + Face::Shx { .. } | Face::WithBig { .. } => { + // A system TTF name never resolves to an SHX shape font + // (nor gains a big font without the pairing separator). eprintln!("Font {} resolved to Shx (unexpected)", test_name); } } } } + + + + #[test] + fn big_font_pairing_routes_ideographs_and_leaves_latin_alone() { + // Without a real big-font file the pairing is dropped: the primary + // face is used as-is. + assert!(matches!( + Face::resolve(&Face::with_big_font("txt", "C:/nowhere/chineset.shx")), + Face::Lff(_) + )); + // A stroke face still renders Latin from its own glyphs when paired. + let face = Face::resolve("txt"); + assert!(face.glyph('A').is_some()); + } } diff --git a/src/scene/text/shx.rs b/src/scene/text/shx.rs index 51828b495..997e48c30 100644 --- a/src/scene/text/shx.rs +++ b/src/scene/text/shx.rs @@ -50,7 +50,20 @@ const DIRS: [[f64; 2]; 16] = [ [1.0, -0.5], ]; +/// Which `AutoCAD-86` container a file is; decides how glyphs are keyed and +/// how a `7` (subshape) opcode is encoded. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ShxKind { + /// `shapes 1.0/1.1`: keyed by 8-bit code (or shape number). + Shapes, + /// `unifont 1.0`: keyed by Unicode code point. + Unifont, + /// `bigfont 1.0`: keyed by a double-byte legacy code (Big5, GBK, …). + Bigfont, +} + struct ShxFile { + kind: ShxKind, /// shape number → (name, spec bytes) shapes: HashMap)>, } @@ -81,39 +94,158 @@ fn load_file(path: &str) -> Option> { fn parse_file(path: &str) -> Option { let bytes = std::fs::read(path).ok()?; - // Header line: `AutoCAD-86 shapes 1.0`/`1.1` + CR LF SUB. Reject fonts. + // Header line: `AutoCAD-86 1.x` + CR LF SUB. let head_end = bytes.iter().position(|&b| b == 0x1A)?; let header = String::from_utf8_lossy(&bytes[..head_end]); - if !header.contains("shapes") { + let kind = if header.contains("shapes") { + ShxKind::Shapes + } else if header.contains("unifont") { + ShxKind::Unifont + } else if header.contains("bigfont") { + ShxKind::Bigfont + } else { return None; - } + }; let mut p = head_end + 1; let rd_u16 = |p: &mut usize| -> Option { let v = u16::from_le_bytes([*bytes.get(*p)?, *bytes.get(*p + 1)?]); *p += 2; Some(v) }; - let _first = rd_u16(&mut p)?; - let _last = rd_u16(&mut p)?; - let count = rd_u16(&mut p)? as usize; - let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count); - for _ in 0..count { - let num = rd_u16(&mut p)?; - let len = rd_u16(&mut p)? as usize; - dir.push((num, len)); - } - let mut shapes = HashMap::new(); - for (num, len) in dir { - let end = p.checked_add(len)?.min(bytes.len()); - let blob = &bytes[p..end]; - p = end; - // Name = leading NUL-terminated ASCII, spec follows. + let rd_u32 = |p: &mut usize| -> Option { + let v = u32::from_le_bytes([ + *bytes.get(*p)?, + *bytes.get(*p + 1)?, + *bytes.get(*p + 2)?, + *bytes.get(*p + 3)?, + ]); + *p += 4; + Some(v) + }; + // Glyph data = leading NUL-terminated ASCII name, spec follows. + let split_named = |blob: &[u8]| -> (String, Vec) { let name_end = blob.iter().position(|&b| b == 0).unwrap_or(0); let name = String::from_utf8_lossy(&blob[..name_end]).into_owned(); let spec = blob.get(name_end + 1..).unwrap_or(&[]).to_vec(); - shapes.insert(num, (name, spec)); + (name, spec) + }; + let mut shapes = HashMap::new(); + match kind { + ShxKind::Shapes => { + let _first = rd_u16(&mut p)?; + let _last = rd_u16(&mut p)?; + let count = rd_u16(&mut p)? as usize; + let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count); + for _ in 0..count { + let num = rd_u16(&mut p)?; + let len = rd_u16(&mut p)? as usize; + dir.push((num, len)); + } + for (num, len) in dir { + let end = p.checked_add(len)?.min(bytes.len()); + let blob = &bytes[p..end]; + p = end; + shapes.insert(num, split_named(blob)); + } + } + ShxKind::Unifont => { + // u32 glyph count (including the font header), then the header + // entry (u16 length + name NUL above below modes encoding + // embedded 0), then `count - 1` × (u16 code, u16 length, data). + let count = rd_u32(&mut p)? as usize; + let head_len = rd_u16(&mut p)? as usize; + let end = p.checked_add(head_len)?.min(bytes.len()); + shapes.insert(0, split_named(&bytes[p..end])); + p = end; + for _ in 1..count { + let Some(code) = rd_u16(&mut p) else { break }; + let Some(len) = rd_u16(&mut p) else { break }; + let end = p.checked_add(len as usize)?.min(bytes.len()); + shapes.insert(code, split_named(&bytes[p..end])); + p = end; + } + } + ShxKind::Bigfont => { + // u16 bytes per table entry (8), u16 table entries, u16 range + // count, ranges × (u16 lo, u16 hi) of lead bytes, then the entry + // table — a sparse hash table of (u16 code, u16 length, u32 file + // offset); empty slots are all zero. Shape 0 (name NUL above + // below modes) is the font header. + let entry_size = rd_u16(&mut p)? as usize; + let entries = rd_u16(&mut p)? as usize; + let ranges = rd_u16(&mut p)? as usize; + p = p.checked_add(ranges * 4)?; + if entry_size < 8 { + return None; + } + for i in 0..entries { + let mut q = p.checked_add(i * entry_size)?; + let Some(code) = rd_u16(&mut q) else { break }; + let Some(len) = rd_u16(&mut q) else { break }; + let Some(off) = rd_u32(&mut q) else { break }; + if off == 0 && len == 0 { + continue; + } + let start = off as usize; + let end = start.saturating_add(len as usize).min(bytes.len()); + if start >= end { + continue; + } + shapes.insert(code, split_named(&bytes[start..end])); + } + } } - Some(ShxFile { shapes }) + Some(ShxFile { kind, shapes }) +} + +/// True when the file is an Asian big font (`AutoCAD-86 bigfont 1.0`). +pub fn is_bigfont(path: &str) -> bool { + load_file(path).is_some_and(|f| f.kind == ShxKind::Bigfont) +} + +/// The double-byte legacy encodings a big font may be keyed by, most likely +/// first for this file name. The font itself does not say; try them in turn +/// and take the first code the font actually has. +fn bigfont_encodings(path: &str) -> [&'static encoding_rs::Encoding; 4] { + use encoding_rs::{BIG5, EUC_KR, GBK, SHIFT_JIS}; + let name = path + .rsplit(['/', '\\']) + .next() + .unwrap_or(path) + .to_ascii_lowercase(); + if name.contains("gb") || name.contains("hz") || name.contains("china") { + [GBK, BIG5, SHIFT_JIS, EUC_KR] + } else if name.contains("bigfont") || name.contains("extfont") || name.contains("jp") { + [SHIFT_JIS, BIG5, GBK, EUC_KR] + } else if name.contains("whg") || name.contains("kor") || name.contains("han") { + [EUC_KR, BIG5, GBK, SHIFT_JIS] + } else { + // `chineset.shx` and most Taiwanese fonts. + [BIG5, GBK, SHIFT_JIS, EUC_KR] + } +} + +/// Look up `ch` in a big font: encode it to the font's double-byte code and +/// fetch that shape, normalised like [`font_glyph`]. `None` when the file is +/// not a big font or has no glyph for the character. +pub fn bigfont_glyph(path: &str, ch: char) -> Option> { + let file = load_file(path)?; + if file.kind != ShxKind::Bigfont { + return None; + } + let mut buf = [0u8; 4]; + let s = ch.encode_utf8(&mut buf); + for enc in bigfont_encodings(path) { + let (bytes, _, unmappable) = enc.encode(s); + if unmappable || bytes.len() != 2 { + continue; + } + let code = u16::from_be_bytes([bytes[0], bytes[1]]); + if file.shapes.contains_key(&code) { + return font_glyph(path, code); + } + } + None } /// Tessellate the shape named `name` (case-insensitive) from the SHX at @@ -410,8 +542,16 @@ fn interpret( } } 7 => { - let sub = *b.get(i).unwrap_or(&0) as u16; + let mut sub = *b.get(i).unwrap_or(&0) as u16; i += 1; + // A big font references subshapes by a two-byte code: + // `7, 0, hi, lo`. + if file.kind == ShxKind::Bigfont && sub == 0 { + let hi = *b.get(i).unwrap_or(&0) as u16; + let lo = *b.get(i + 1).unwrap_or(&0) as u16; + i += 2; + sub = (hi << 8) | lo; + } if !skipping && sub != 0 && sub != shape_number { interpret(file, sub, st, cur, out, depth + 1); } @@ -510,3 +650,91 @@ fn interpret( } } } + + +#[cfg(test)] +mod tests { + use super::*; + + /// Shape bytecode for a glyph: a horizontal 6-unit stroke, then end. + /// (`0x60` = length 6, direction 0.) + const BAR: [u8; 2] = [0x60, 0x00]; + + fn tmp(name: &str, bytes: &[u8]) -> String { + let dir = std::env::temp_dir().join(format!("ocs-shx-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() + } + + /// A one-glyph `bigfont 1.0` keyed by the Big5 code of `中` (0xA4A4), + /// laid out like `chineset.shx`: header, 8-byte entries in a sparse + /// table, font header shape 0 (`name NUL above below modes`). + fn synthetic_bigfont() -> Vec { + let mut b = b"AutoCAD-86 bigfont 1.0\r\n\x1a".to_vec(); + let entries: u16 = 4; + b.extend_from_slice(&8u16.to_le_bytes()); + b.extend_from_slice(&entries.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); // one lead-byte range + b.extend_from_slice(&0x80u16.to_le_bytes()); + b.extend_from_slice(&0xFFu16.to_le_bytes()); + let table_at = b.len(); + b.resize(table_at + 8 * entries as usize, 0); + let head_data: Vec = [b"BIG\x00".as_slice(), &[48u8, 0, 2]].concat(); + let glyph_data: Vec = [b"\x00".as_slice(), &BAR].concat(); + let head_off = b.len() as u32; + b.extend_from_slice(&head_data); + let glyph_off = b.len() as u32; + b.extend_from_slice(&glyph_data); + // Slot 0 = shape 0; slot 2 = the glyph (slots 1 and 3 stay empty). + let mut entry = |slot: usize, code: u16, len: u16, off: u32| { + let at = table_at + slot * 8; + b[at..at + 2].copy_from_slice(&code.to_le_bytes()); + b[at + 2..at + 4].copy_from_slice(&len.to_le_bytes()); + b[at + 4..at + 8].copy_from_slice(&off.to_le_bytes()); + }; + entry(0, 0, head_data.len() as u16, head_off); + entry(2, 0xA4A4, glyph_data.len() as u16, glyph_off); + b + } + + /// A `unifont 1.0` with the font header and one glyph for `A` (U+0041). + fn synthetic_unifont() -> Vec { + let mut b = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec(); + b.extend_from_slice(&2u32.to_le_bytes()); // header + 1 glyph + let head: Vec = [b"UNI\x00".as_slice(), &[6u8, 2, 2, 0, 0, 0]].concat(); + b.extend_from_slice(&(head.len() as u16).to_le_bytes()); + b.extend_from_slice(&head); + let glyph: Vec = [b"\x00".as_slice(), &BAR].concat(); + b.extend_from_slice(&0x41u16.to_le_bytes()); + b.extend_from_slice(&(glyph.len() as u16).to_le_bytes()); + b.extend_from_slice(&glyph); + b + } + + #[test] + fn bigfont_glyph_is_found_by_big5_code_and_scaled_to_cap_height() { + let path = tmp("cjk.shx", &synthetic_bigfont()); + assert!(is_bigfont(&path)); + let (above, below) = font_metrics(&path).expect("font header"); + assert_eq!((above, below), (48.0, 0.0)); + // `中` → Big5 A4A4 → the glyph; `A` is not in a big font. + let g = bigfont_glyph(&path, '中').expect("glyph for 中"); + // 6 font units at above = 48 → 6 * 9 / 48 = 1.125 text units. + assert!((g.advance - 1.125).abs() < 1e-4, "advance {}", g.advance); + assert_eq!(g.strokes.len(), 1); + assert!(bigfont_glyph(&path, 'A').is_none()); + assert!(bigfont_glyph(&path, '國').is_none(), "unknown code must miss, not alias"); + } + + #[test] + fn unifont_glyph_is_keyed_by_unicode() { + let path = tmp("uni.shx", &synthetic_unifont()); + assert!(!is_bigfont(&path)); + assert_eq!(font_metrics(&path), Some((6.0, 2.0))); + let g = font_glyph(&path, 'A' as u16).expect("glyph for A"); + assert!((g.advance - 9.0).abs() < 1e-4, "6 units at above 6 → 9: {}", g.advance); + assert!(font_glyph(&path, 'B' as u16).is_none()); + } +} From 24f8e124d52c62ca8a658c6e06e1259dcab310e8 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 21:01:05 +0800 Subject: [PATCH 4/6] fix(text): re-lay-out cached glyph quads after the SDF atlas grows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glyph quads bake the atlas UV of their tile. `grow_height` rescales every cached entry's V and bumps the atlas generation, and the tessellation memo folds that generation in — but the caches that hold finished wires do not: the resident wire set is keyed by geometry epoch, block definitions by block epoch, and a full build can grow the atlas part-way through (a CJK sheet set bakes thousands of tiles on first sight). Text tessellated before the growth then samples the wrong tiles for as long as those caches live, which on a big drawing is forever: whole tables of scrambled glyphs, and the block contents stayed scrambled even after a REGEN. - `resident_wires_for` rebuilds (dropping the block definition cache) until the atlas generation is stable across the pass. - `Scene::update` compares the live generation with the one the scene's caches were built against and bumps geometry once when it moved, so a later growth (opening a layout, editing text) re-lays-out everything. Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/camera_ops.rs | 13 ++++++++++++- src/scene/mod.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index a1db36b48..3ee2e7ce1 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1566,5 +1566,16 @@ impl Scene { self.camera_generation += 1; } - pub fn update(&mut self, _dt: Duration) {} + pub fn update(&mut self, _dt: Duration) { + // The glyph atlas grew or re-baked since this scene last built its + // caches (a CJK sheet set bakes thousands of tiles on first sight): + // every quad laid out before that samples the wrong tile — scrambled + // text in the affected blocks / viewports. One structural bump + // re-lays-out all text against the settled atlas. + let atlas_gen = crate::scene::text::sdf_atlas::generation(); + if self.last_atlas_generation.get() != atlas_gen { + self.last_atlas_generation.set(atlas_gen); + self.bump_geometry(); + } + } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e2b373127..4fc66e527 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2348,6 +2348,11 @@ pub struct Scene { /// (re)built, otherwise the held value. `build_primitive` reads it right /// after the call to gate GPU wire re-upload. 0 = none yet. pub(crate) last_model_wire_gen: std::cell::Cell, + /// SDF glyph-atlas generation this scene's caches were built against. + /// When the atlas grows or re-bakes, every cached glyph quad (resident + /// sets, block caches, projected viewport copies) addresses the wrong + /// tile; `update` compares against the live generation and rebuilds. + pub(crate) last_atlas_generation: std::cell::Cell, /// Interaction-LOD state: `camera_generation` seen on the previous frame and /// the wall time it last changed. Used to detect "the view is actively being /// panned / zoomed / orbited" so the expensive per-pixel hatch pass can be @@ -2638,6 +2643,7 @@ impl Scene { last_tess_ms: std::cell::Cell::new(0.0), last_tess_wires: std::cell::Cell::new(0), last_model_wire_gen: std::cell::Cell::new(0), + last_atlas_generation: std::cell::Cell::new(crate::scene::text::sdf_atlas::generation()), nav_last_gen: std::cell::Cell::new(0), nav_changed_at: std::cell::Cell::new(None), nav_perf_pending: std::cell::Cell::new(None), @@ -6105,6 +6111,7 @@ impl Scene { // Build once: full tessellation, no cull, no zoom LOD — the resident // set is zoom-independent (GPU analytical circles/arcs/ellipses). let t_tess = iced::time::Instant::now(); + let mut atlas_gen = crate::scene::text::sdf_atlas::generation(); let mut wires = self.wires_for_block_culled( block, None, @@ -6115,6 +6122,34 @@ impl Scene { all_visible, style_viewport, ); + // Baking a drawing's glyphs for the first time can grow the SDF atlas + // part-way through this pass (a CJK sheet set bakes thousands of + // tiles). Text tessellated before the growth carries UVs that now + // address the wrong tiles — visible as scrambled glyphs — and this + // set is cached by geometry epoch, so nothing would ever re-lay it + // out. The memo guard already folds the generation in, so rebuilding + // once the atlas has settled re-tessellates only the stale text. + for _ in 0..3 { + let now = crate::scene::text::sdf_atlas::generation(); + if now == atlas_gen { + break; + } + atlas_gen = now; + // Block definitions tessellated earlier in this pass hold the + // stale quads too, and their cache is keyed by block epoch (which + // has not moved), so drop them or the rebuild would reuse them. + self.block_defn_cache.borrow_mut().clear(); + wires = self.wires_for_block_culled( + block, + None, + None, + frozen_layers, + anno_scale_override, + annotation_scale_handle, + all_visible, + style_viewport, + ); + } let perf = crate::perf::enabled(); let t_post = perf.then(iced::time::Instant::now); // Synthesized nonprint markers (geo-location daisy) live in model space From 876513d009f8c7a69d7b42b9649eb29eecda9861 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 22:02:13 +0800 Subject: [PATCH 5/6] fix(mtext): wrap between ideographs; paragraph indents and tabs in text-height units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layout defects that together broke every numbered note in a Chinese drawing: - Words were split on whitespace only, so a Chinese sentence — which has no spaces — was one unbreakable word. When it did not fit after "7." the whole sentence dropped to the next line and still ran past the column. Ideographic text may break between any two characters except before a closing mark or after an opening one (the basic kinsoku rule); the atom splitter now emits those break opportunities. - `\pxi…,l…,r…,t…;` paragraph indents and tab stops are multiples of the text height, and the first-line indent is relative to the left indent (a hanging list is `i-1.5,l1.5`). They were applied as raw drawing units, i.e. a fraction of a unit, so wrapped lines snapped back to the margin and the number/tab column collapsed. Scale them by the entity height and resolve the first line from the left indent. Adds a layout test: a 40-ideograph paragraph in a 30-unit column wraps into several lines, none wider than the column. Co-Authored-By: Claude Opus 5 (1M context) --- src/entities/text_support.rs | 96 ++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index d4adac03f..57e865093 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -560,11 +560,18 @@ pub fn adapt_mtext_paragraphs( } let (align, indent_first, indent_left, indent_right, tab_stops) = if has_own_props(props) || carried.is_none() { + // `\pxi…,l…,r…,t…;` values are multiples of the text height, + // not drawing units, and the first-line indent is relative to + // the left indent (a numbered list's hanging indent is + // `i-1.5,l1.5`: number at the margin, wrapped lines 1.5 h in). + // Taken raw they were a fraction of a unit — no indent at all — + // so every wrapped list line snapped back to the margin. + let left = props.left_margin.unwrap_or(0.0) as f32 * entity_height; let v = ( props.alignment.and_then(map_align), - props.first_line_indent.unwrap_or(0.0) as f32, - props.left_margin.unwrap_or(0.0) as f32, - props.right_margin.unwrap_or(0.0) as f32, + left + props.first_line_indent.unwrap_or(0.0) as f32 * entity_height, + left, + props.right_margin.unwrap_or(0.0) as f32 * entity_height, props .tab_stops .iter() @@ -577,7 +584,7 @@ pub fn adapt_mtext_paragraphs( ATab::Decimal(_) => TabKind::Decimal, }; TabStop { - position: ts.position() as f32, + position: ts.position() as f32 * entity_height, kind, } }) @@ -900,6 +907,20 @@ pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow< } } +/// Whether a line may break between `prev` and `next`. Ideographic text has +/// no spaces, so a break is allowed on either side of a full-width character, +/// except before a closing mark (`,。、)」…`) or after an opening one +/// (`(「《…`) — the basic kinsoku rule. +pub(crate) fn cjk_break_between(prev: char, next: char) -> bool { + use crate::scene::text::ttf_glyph::is_full_width; + const CLOSING: &str = ",。、;:?!)」』】〕》〉』﹚﹞.~…‥、〞”’,.;:!?)]}%"; + const OPENING: &str = "(「『【〔《〈﹙﹝“‘([{"; + if !(is_full_width(prev) || is_full_width(next)) { + return false; + } + !CLOSING.contains(next) && !OPENING.contains(prev) +} + pub fn measure_word( text: &str, state: &RunState, @@ -1404,6 +1425,21 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { doc_char_offset += 1; word_start = doc_char_offset; } else { + // CJK text carries no spaces: every ideograph is a + // wrap opportunity (bar the usual "no line starts + // with a closing mark / ends with an opening one" + // rule), so a long Chinese sentence must not stay + // one unbreakable word that overshoots its column. + if let Some(prev) = word.chars().last() { + if cjk_break_between(prev, ch) { + atoms.push(LayoutAtom { + kind: AtomKind::Word(std::mem::take(&mut word)), + state: run.state.clone(), + char_offset: word_start, + }); + word_start = doc_char_offset; + } + } word.push(ch); doc_char_offset += 1; } @@ -2997,6 +3033,58 @@ mod v_anchor_tests { assert_eq!(reordered6, vec!["שלום", " ", "«x"]); } + + #[test] + fn cjk_paragraph_wraps_between_ideographs() { + use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle}; + // Breaks are allowed between ideographs but not before a closing mark + // or after an opening one. + assert!(cjk_break_between('工', '程')); + assert!(cjk_break_between('程', 'A')); + assert!(!cjk_break_between('工', ',')); + assert!(!cjk_break_between('(', '工')); + assert!(!cjk_break_between('a', 'b')); + + let style = ResolvedTextStyle { + font_name: "Standard".to_string(), + width_factor: 1.0, + oblique_angle: 0.0, + is_backward: false, + is_upside_down: false, + is_vertical: false, + }; + // 40 ideographs with no spaces in a column ~12 characters wide used + // to stay one unbreakable word: a single line overshooting the box. + let value = "本工程之各種平面圖均須相互配合如有不符或須更正處均按工程慣例施工至完整並請於估價前提出"; + let opts = MTextRenderOpts { + columns: Default::default(), + value, + insertion: [0.0, 0.0, 0.0], + height: 2.5, + rect_w: 30.0, + rotation: 0.0, + style: &style, + attach_h_anchor: 0.0, + v_anchor: MTextVAnchor::Top, + line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, + vertical_text: false, + want_glyph_boxes: true, + }; + let layout = layout_mtext(&opts); + assert!( + layout.line_count >= 3, + "a 40-ideograph paragraph in a 30-unit column must wrap (got {} line(s))", + layout.line_count + ); + assert!( + layout.line_widths.iter().all(|w| *w <= 30.0 + 1e-3), + "no wrapped line may overshoot the column: {:?}", + layout.line_widths + ); + } + #[test] fn test_mtext_arabic_bidi_layout() { use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle}; From 4826cd79897108d63370d7a313b816d4641abb07 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 22:02:13 +0800 Subject: [PATCH 6/6] fix(pdf): Flate-compress the exported PDF streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit printpdf 0.9's `PdfSaveOptions::optimize` is a no-op — its `doc.compress()` call is commented out — so every page's content stream (megabytes of vector operators for a CAD sheet) was written uncompressed: a 16-sheet drawing set exported as 280 MB. Serialise through `printpdf::to_lopdf_doc` and run lopdf's `compress()` before writing; the same export is now 49 MB and readers see ordinary FlateDecode streams. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 2 ++ src/io/pdf_export.rs | 11 ++++++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e50a11fdc..191ee67f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,7 @@ dependencies = [ "js-sys", "libloading", "log", + "lopdf", "lyon_tessellation", "meshopt", "naga 27.0.3", diff --git a/Cargo.toml b/Cargo.toml index b8d1a54fd..f65b42ce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,8 @@ i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" printpdf = { version = "0.9.1", default-features = false, features = ["images"] } +# The lopdf printpdf builds on: used to Flate-compress the exported PDF streams. +lopdf = { version = "0.39.0", default-features = false, features = ["time"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 774ec9afa..4b505bc93 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -424,7 +424,16 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) .map_err(|error| format!("Page {}: {error}", index + 1))?; } let mut warnings = Vec::new(); - Ok(doc.save(&PdfSaveOptions::default(), &mut warnings)) + // printpdf 0.9's `optimize` is a no-op (its `doc.compress()` is commented + // out), so every page's content stream — megabytes of vector operators + // for a CAD sheet — went out uncompressed: a 16-sheet set weighed 280 MB. + // Serialise through lopdf ourselves and Flate-compress the streams. + let mut lo = printpdf::to_lopdf_doc(&doc, &PdfSaveOptions::default(), &mut warnings); + lo.compress(); + let mut bytes = Vec::new(); + lo.save_to(&mut bytes) + .map_err(|error| format!("PDF serialisation failed: {error}"))?; + Ok(bytes) } #[cfg(not(target_arch = "wasm32"))]