diff --git a/Cargo.lock b/Cargo.lock index 4da766abd..191ee67f3 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", @@ -37,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 6e78631dc..f65b42ce0 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] @@ -90,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/entities/text_support.rs b/src/entities/text_support.rs index 743fe85ef..57e865093 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), @@ -539,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() @@ -556,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, } }) @@ -879,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, @@ -1383,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; } @@ -2976,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}; 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/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"))] 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 d3e6b5c95..f44d948eb 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2357,6 +2357,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 @@ -2651,6 +2656,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), @@ -6120,6 +6126,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, @@ -6130,6 +6137,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 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/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()); + } +} 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(); 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 + ); +}