Browse / github.com/storytold/deckcraft / patch
text: CJK document families resolve to their open twins; spAutoFit single-line labels never wrap
text: CJK document families resolve to their open twins; spAutoFit single-line labels never wrap ## What Chinese course decks set their Latin runs in Times New Roman and their CJK in 黑体/微软雅黑. When those proprietary families are missing, 黑体 et al fell down the Calibri chain: the CJK 1em glyphs still broke lines the same way, but **spaces and digits narrowed**, so space-padded layouts shifted and the overlaid labels (归纳, equation objects) were covered. - `substitutes()` / `has_metric_twin()` gain the CJK mainstays: 黑体/SimHei, 微软雅黑/等线 → Noto Sans CJK SC (+Source Han Sans SC); 宋体/SimSun → Noto Serif CJK SC. CJK glyphs are 1em in both, so line breaking is unchanged; spaces/digits widen t
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- amosarc (imported from pull request #1096, open)
- Base
f34f4121c8ee720ca08e5d47b912155a33504e69- Size
- +344 −33 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
92e375eeae232dcb81b7a491
How it went
No other operator has reported using it yet.
Diff
raw · crates/fonts/src/fontdb.rs crates/fonts/src/script.rs crates/render/src/metafile.rs crates/render/src/tests.rs crates/text/src/lib.rs crates/text/src/tests.rs
@@ -39,6 +39,17 @@ pub fn substitutes(family: &str) -> &'static [&'static str] { "aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => { &["Inter", "Helvetica Neue", "Arial", "Liberation Sans"] }+ // 中文课件的主力族:CJK 字形一律 1em 等宽,换行不受影响;Noto/思源是+ // craft-fonts 收录的官方开源形(黑体类→Noto Sans CJK SC,宋体类→Noto Serif)。+ "黑体" | "simhei" | "微软雅黑" | "microsoft yahei" | "microsoft yahei ui" | "等线" | "dengxian" => {+ &["Noto Sans CJK SC", "Source Han Sans SC", "Noto Sans CJK JP"]+ }+ "宋体" | "simsun" | "新宋体" | "nsimsun" | "思源宋体" | "source han serif" | "华文中宋" => {+ &["Noto Serif CJK SC", "Source Han Serif SC"]+ }+ "楷体" | "kaiti" | "华文楷体" | "stkaiti" | "华文新魏" | "stxinwei" | "华文行楷" | "stxingkai" | "隶书" | "lisu" => {+ &["Noto Sans CJK SC", "Source Han Sans SC"]+ } "georgia" | "garamond" | "book antiqua" | "palatino" | "palatino linotype" | "constantia" => &["Merriweather", "Liberation Serif", "Georgia"], "century gothic" | "avenir" | "avenir next" | "futura" | "gill sans" | "gill sans mt" | "trebuchet ms" | "corbel" | "candara" => { &["Montserrat", "Poppins", "Avenir Next", "Inter"]@@ -55,9 +66,28 @@ pub fn substitutes(family: &str) -> &'static [&'static str] { /// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with /// them breaks into the same lines. fn has_metric_twin(family: &str) -> bool {+ let binding = family.to_ascii_lowercase();+ let f = binding.as_str(); matches!(- family.to_ascii_lowercase().as_str(),+ f, "calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"+ ) || matches!(+ // 中文课件主力族:Noto/思源与其 CJK 字形同为 1em 等宽,换行一致。+ f,+ "黑体"+ | "simhei"+ | "微软雅黑"+ | "microsoft yahei"+ | "microsoft yahei ui"+ | "等线"+ | "dengxian"+ | "宋体"+ | "simsun"+ | "新宋体"+ | "nsimsun"+ | "思源宋体"+ | "source han serif"+ | "华文中宋" ) } @@ -1428,6 +1458,26 @@ impl OutlinePen for FlipPen { } } +#[cfg(test)]+mod cjk_document_tests {+ use super::*;++ /// 中文课件主力族(黑体/微软雅黑/宋体)在机器未装时落到 craft-fonts 收录的+ /// Noto/思源形——而不是 Calibri 一系:CJK 1em 等宽保证换行一致。+ #[test]+ fn cjk_document_families_resolve_to_their_open_twin() {+ let db = FontDb::global();+ let Some(twin) = ["Noto Sans CJK SC", "Source Han Sans SC"].into_iter().find(|f| db.has_family(f)) else {+ eprintln!("skipped: 本机与 craft-fonts 均无 Noto/思源黑体");+ return;+ };+ for family in ["黑体", "微软雅黑", "等线"] {+ let face = db.document_face(family, "Regular");+ assert!(face.family.eq_ignore_ascii_case(twin), "{family} → {},应为 {twin}", face.family);+ }+ }+}+ #[cfg(test)] mod font_dir_tests { use std::path::{Path, PathBuf};@@ -133,6 +133,16 @@ pub fn slot_of(c: char) -> Option<FontSlot> { None } +/// Is `c` a Common character Office classifies by code range rather than by its neighbours, so+/// the run's Latin typeface draws it in every script context (ECMA-376 font classes): ASCII —+/// spaces, digits, punctuation — and every code point outside the East Asian and Arabic ranges+/// (`≥`, `“`, `—`, `°`). Only the Arabic punctuation set, the harakat and BOM-like marks follow+/// the surrounding text.+pub fn latin_neutral(c: char) -> bool {+ let cp = c as u32;+ script_of(c).is_none() && slot_of(c).is_none() && !arabic_common(cp)+}+ /// A default BCP 47 language for a script (shaping picks `locl` forms by language). pub fn default_language(s: ScriptTag) -> Option<&'static str> { Some(match &s {@@ -243,4 +253,28 @@ mod tests { assert!(w[0].1 < w[1].0, "{:x?} {:x?}", w[0], w[1]); } }++ #[test]+ fn latin_neutral_by_code_range_not_neighbours() {+ // ASCII: spaces, digits, punctuation take the Latin typeface whatever surrounds them.+ for c in [' ', '\t', '\u{00A0}', '2', '(', ')', '+', '=', ',', '.', ':'] {+ assert!(latin_neutral(c), "{c:?}");+ }+ // High ANSI neutrals outside the East Asian and Arabic ranges too.+ for c in ['\u{2265}', '\u{201C}', '\u{2014}', '\u{00B0}', '\u{00B1}'] {+ assert!(latin_neutral(c), "{c:?}");+ }+ // East Asian punctuation and full-width forms are East Asian (slot_of decides).+ for c in [',', '。', '、', '\u{3000}', '%'] {+ assert!(!latin_neutral(c), "{c:?}");+ }+ // The Arabic punctuation set, harakat and tatweel follow the surrounding text.+ for c in ['\u{060C}', '\u{061B}', '\u{061F}', '\u{0640}', '\u{064E}'] {+ assert!(!latin_neutral(c), "{c:?}");+ }+ // Strong characters are never neutral.+ assert!(!latin_neutral('a'));+ assert!(!latin_neutral('日'));+ assert!(!latin_neutral('س'));+ } }@@ -181,9 +181,10 @@ fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) { if !k.is_finite() || k <= 0.0 { return; }- // Characters as drawn: symbol fonts (Wingdings…) map like bullets; each character takes the- // face that has it.- let chars: Vec<char> = t.text.chars().map(|c| deckcraft_text::symbol_char(&face, family, c).unwrap_or(c)).collect();+ // Characters as drawn: symbol fonts (Wingdings…) map like bullets, a SYMBOL_CHARSET font+ // that is not a symbol family falls back from its U+F000+byte codes to the bytes' characters+ // where the face lacks them; each character takes the face that has it.+ let chars: Vec<char> = t.text.chars().map(|c| deckcraft_text::metafile_char(&face, family, font.charset, c)).collect(); let faces: Vec<Arc<FontFace>> = chars .iter() .map(|c| {@@ -2595,3 +2595,25 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179)); assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); }++#[test]+fn metafile_symbol_charset_text_in_a_latin_font_draws_the_characters() {+ // MathType writes equation text with "Times New Roman" in the symbol character set. Whether+ // the font is installed (its U+F000+byte glyph) or missing (the byte's own character), a "+"+ // draws a plus — not a .notdef box or a bullet.+ let mut f = i16s(&[-720, 0, 0, 0, 400]);+ f.extend([0, 0, 0, 2, 0, 0, 0, 0]); // charset 2: SYMBOL_CHARSET+ f.extend(b"Times New Roman");+ f.push(0);+ let mut recs = vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[0]))];+ recs.push(text_record(1440, 360, "+", &[]));+ let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");+ let (x0, y0, x1, y1) = mf_ink(&img).expect("plus ink");+ assert!(x1 - x0 > 12 && y1 - y0 > 12, "a sizeable mark: {x0} {y0} {x1} {y1}");+ let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);+ let on = |x: u32, y: u32| img.get_pixel(x, y)[3] > 100;+ assert!(on(cx, cy), "ink at the crossing");+ assert!(on(x0 + 2, cy) && on(x1 - 2, cy), "the horizontal bar");+ assert!(on(cx, y0 + 2) && on(cx, y1 - 2), "the vertical bar");+ assert!(!on(x0 + 1, y0 + 1) && !on(x1 - 1, y1 - 1), "empty corners: a plus, not a box or bullet");+}@@ -860,10 +860,15 @@ fn shape_cells( let Some(st) = styles.get(si) else { continue }; let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin)); // Spaces, digits and punctuation stay in the face of the text before them when it can- // draw them, so a phrase isn't cut into items at every space.+ // draw them, so a phrase isn't cut into items at every space — except the Common+ // characters Office classifies by code range (ECMA-376): ASCII spaces, digits and+ // punctuation, and high-ANSI neutrals such as `≥` or `“`, take the run's Latin typeface+ // whatever surrounds them (a space after a Han character is a narrow Latin one, and a+ // line of space-padded text wraps where PowerPoint's does). // Emoji, and pictographs asked to look like text (U+FE0E), pick their own face. let neutral = cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');+ let latin = neutral && cluster.iter().all(|c| script::latin_neutral(*c)); // A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there. let sym_face = st .sym@@ -875,11 +880,16 @@ fn shape_cells( let face = match (sym_face, prev.as_ref()) { (Some(f), _) => f, (None, Some(p))- if neutral && !p.is_color() && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) =>+ if neutral+ && !latin+ && !p.is_color()+ && deckcraft_fonts::covers_cluster(p, cluster)+ && char_style.get(a.wrapping_sub(1)) == Some(&si) => { p.clone() } (None, _) => {+ let slot = if latin { FontSlot::Latin } else { slot }; let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc)); db.cascade(cluster, chain, st.style_name) }@@ -1082,7 +1092,7 @@ fn shape_item( let s = scale_of(cell); let w = width.entry(h).or_insert(0.0); cell.glyphs.push((g.gid, *w + g.x_offset as f64 * s, -(g.y_offset as f64) * s));- *w += g.x_advance as f64 * s;+ *w += glyph_advance(g.x_advance as f64 * s); let e = lig.entry(h).or_insert((g.gid, 0)); e.1 += 1; }@@ -1137,6 +1147,16 @@ fn is_symbol_font(family: &str) -> bool { f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett") } +/// A glyph's advance as PowerPoint measures it: Office lays text out in hundredths of a point (the+/// unit of `sz` and `spc`), quantizing each glyph's advance down. A line of space-padded course+/// text then fits its box by the width PowerPoint saw rather than exact font units — a 32 pt Times+/// New Roman line of 35 glyphs measures 0.18 pt narrower — and wraps where PowerPoint's does+/// (measured against PowerPoint on a deck whose first line fits its box by two hundredths of a+/// point).+fn glyph_advance(adv: f64) -> f64 {+ (adv * 100.0).floor() / 100.0+}+ /// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a /// symbol font that `face` is (not a substitute) and it has the glyph. fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {@@ -1169,6 +1189,25 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> { Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•'))) } +/// `SYMBOL_CHARSET` (MS-WMF LOGFONT): the text bytes are the font's own symbol codes, drawn from+/// the U+F000 + byte code point.+pub const SYMBOL_CHARSET: u8 = 2;++/// The character a metafile text item draws for `c` (one decoded character of the record's text)+/// in the font of `charset`: symbol-font families map their codes as bullets do+/// ([`symbol_char`]), and a `SYMBOL_CHARSET` font that is not a symbol family — MathType writes+/// "Times New Roman" with the symbol character set — keeps its U+F000 + byte code where `face`+/// draws it there and falls back to the byte's own character where it doesn't (the requested+/// font is missing), rather than to a .notdef box.+pub fn metafile_char(face: &FontFace, family: &str, charset: u8, c: char) -> char {+ let c = symbol_char(face, family, c).unwrap_or(c);+ if charset == SYMBOL_CHARSET && ('\u{F000}'..='\u{F0FF}').contains(&c) && !face.covers(c) {+ char::from_u32(c as u32 & 0xFF).unwrap_or(c)+ } else {+ c+ }+}+ /// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character. pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase();@@ -1187,32 +1226,43 @@ pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { }; return m.to_string(); }- if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {+ if f.starts_with("wingdings 2") {+ let code = (first as u32) & 0xff;+ let m = match code {+ 0x97 | 0x98 => '●',+ 0x9e..=0xa1 => '■',+ 0xa2..=0xa5 => '◆',+ 0xea..=0xf3 => '★',+ 0xb7..=0xbf => '○',+ 0x50 => '✓',+ _ => '•',+ };+ return m.to_string();+ }+ if f.starts_with("wingdings 3") {+ let code = (first as u32) & 0xff;+ let m = match code {+ 0x7d | 0x75 => '►',+ 0x7c | 0x74 => '◄',+ 0x70 => '▲',+ 0x71 => '▼',+ 0xc6 | 0xc7 => '➔',+ _ => '▶',+ };+ return m.to_string();+ }+ // The Adobe Symbol encoding is not a bullet set: it is the public reference mapping of the+ // Symbol font's codes (Greek, the math operators). Where a code has no Unicode equivalent+ // here, the character itself stands in rather than a bullet.+ if f.contains("symbol") {+ let code = ((first as u32) & 0xff) as u8;+ return match symbol_encoding(code) {+ 0 => c.to_string(),+ v => char::from_u32(v).map(String::from).unwrap_or_else(|| c.to_string()),+ };+ }+ if f.contains("wingdings") || ('\u{f000}'..='\u{f0ff}').contains(&first) { let code = (first as u32) & 0xff;- // Wingdings 2 and 3 put different shapes at the same codes.- if f.starts_with("wingdings 2") {- let m = match code {- 0x97 | 0x98 => '●',- 0x9e..=0xa1 => '■',- 0xa2..=0xa5 => '◆',- 0xea..=0xf3 => '★',- 0xb7..=0xbf => '○',- 0x50 => '✓',- _ => '•',- };- return m.to_string();- }- if f.starts_with("wingdings 3") {- let m = match code {- 0x7d | 0x75 => '►',- 0x7c | 0x74 => '◄',- 0x70 => '▲',- 0x71 => '▼',- 0xc6 | 0xc7 => '➔',- _ => '▶',- };- return m.to_string();- } let m = match code { 0xa7 => '▪', 0xd8 => '➢',@@ -1232,6 +1282,45 @@ pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { c.graphemes(true).next().unwrap_or("•").to_string() } +/// The Unicode code point of a code in the Adobe Symbol encoding (the published mapping of the+/// Symbol font; AGLFN/Symbol.txt), or 0 where the encoding has no equivalent.+fn symbol_encoding(code: u8) -> u32 {+ const T: &[u16; 224] = &[+ // 0x20..=0x2F: space ! ∀ # ∃ % & ∋ ( ) ∗ + , − . /+ 0x20, 0x21, 0x2200, 0x23, 0x2203, 0x25, 0x26, 0x220B, 0x28, 0x29, 0x2217, 0x2B, 0x2C, 0x2212, 0x2E, 0x2F, //+ // 0x30..=0x3F: digits : ; < = > ? ≅+ 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x2245, //+ // 0x40..=0x4F: Α Β Χ Δ Ε Φ Γ Η Ι ϑ Κ Λ Μ Ν Ο Π+ 0x391, 0x392, 0x3A7, 0x394, 0x395, 0x3A6, 0x393, 0x397, 0x399, 0x3D1, 0x39A, 0x39B, 0x39C, 0x39D, 0x39F, 0x3A0, //+ // 0x50..=0x5F: Θ Ρ Σ Τ Υ ς Ω Ξ Ψ Ζ [ ∴ ] ⊥ _+ 0x398, 0x3A1, 0x3A3, 0x3A4, 0x3A5, 0x3C2, 0x3A9, 0x39E, 0x3A8, 0x396, 0x5B, 0x2234, 0x5D, 0x22A5, 0x5F, 0, //+ // 0x60..=0x6F: ` α β χ δ ε φ γ η ι ϕ κ λ μ ν ο+ 0, 0x3B1, 0x3B2, 0x3C7, 0x3B4, 0x3B5, 0x3C6, 0x3B3, 0x3B7, 0x3B9, 0x3D5, 0x3BA, 0x3BB, 0x3BC, 0x3BD, 0x3BF, //+ // 0x70..=0x7F: π θ ρ σ τ υ ϖ ω ξ ψ ζ { | } ∼ (0x7F unmapped)+ 0x3C0, 0x3B8, 0x3C1, 0x3C3, 0x3C4, 0x3C5, 0x3D6, 0x3C9, 0x3BE, 0x3C8, 0x3B6, 0x7B, 0x7C, 0x7D, 0x223C, 0, //+ // 0x80..=0x8F, 0x90..=0x9F, 0xA0: € then nothing mapped+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //+ 0x20AC, //+ // 0xA1..=0xAF: ϒ ′ ≤ ⁄ ∞ ƒ ♣ ♦ ♥ ♠ ↔ ← ↑ → ↓+ 0x3D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, 0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, //+ // 0xB0..=0xBF: ° ± ″ ≥ × ∝ ∂ • ÷ ≠ ≡ ≈ … (three spacing/position codes)+ 0xB0, 0xB1, 0x2033, 0x2265, 0xD7, 0x221D, 0x2202, 0x2022, 0xF7, 0x2260, 0x2261, 0x2248, 0x2026, 0, 0, 0, //+ // 0xC0..=0xCF: ℑ ℜ ℘ ⊗ ⊕ ∅ ∩ ∪ ⊃ ⊂ ⊄ ⊆ ⊇ ∈ ∉ ∠+ 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, 0x222A, 0x2283, 0x2282, 0x2284, 0x2286, 0x2287, 0x2208, 0x2209, 0x2220, //+ // 0xD0..=0xDF: ∇ ® © ™ ∏ ∑ √ ∙ ¬ ∧ ∨ ⇔ ⟨ (three unmapped)+ 0x2207, 0xAE, 0xA9, 0x2122, 0x220F, 0x2211, 0x221A, 0x2219, 0xAC, 0x2227, 0x2228, 0x21D4, 0x2329, 0, 0, 0, //+ // 0xE0..=0xEF: (unmapped)+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //+ // 0xF0..=0xFF: ∫ ⌠ ⌡ (similar) ◊ (the rest unmapped)+ 0, 0, 0x222B, 0x2320, 0x2321, 0, 0x25CA, 0, 0, 0, 0, 0, 0, 0, 0, 0, //+ ];+ match usize::from(code).checked_sub(0x20) {+ Some(i) => T.get(i).copied().map_or(0, u32::from),+ None => 0,+ }+}+ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { let g = deckcraft_fonts::shape(face, s, &[], |c| c); let k = size / face.upem.max(1.0);@@ -1631,7 +1720,27 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts ); // Text that follows a WordArt arch or circle is set on one line per paragraph. let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);- let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));+ // A spAutoFit text box whose saved height fits exactly one line is a single-line+ // label the author sized by fit-to-text. Renderers whose metrics differ slightly+ // must not fold it into two lines at the stored width (a WPS-authored "变式3:"+ // label broke apart) — render it on one line, overflowing to the right instead.+ let single_line_autofit = matches!(bp.autofit, Some(AutoFit::Shape)) && shape.text_box && body.paragraphs.len() == 1 && {+ let max_sz = body+ .paragraphs+ .iter()+ .flat_map(|p| p.runs.iter())+ .filter(|r| !r.text.trim().is_empty())+ .map(|r| r.props.size.unwrap_or(18.0))+ .fold(0.0f64, f64::max)+ .max(1.0);+ let v_insets = bp.inset_t.unwrap_or(3.6) + bp.inset_b.unwrap_or(3.6);+ let one_line = max_sz * LINE_PITCH + v_insets;+ r.height() > 0.0 && r.height() <= one_line + 2.0+ };+ let wrap = bp.wrap.unwrap_or(true)+ && !follow+ && !single_line_autofit+ && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0);@@ -1467,3 +1467,98 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() { assert_eq!(l.lines.len(), 2); assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item"); }++#[test]+fn ascii_neutrals_keep_the_latin_face_after_east_asian_text() {+ // ECMA-376 font classes: ASCII spaces, digits and punctuation, and high-ANSI neutrals such+ // as ≥, draw with the run's Latin typeface whatever surrounds them, so a line of+ // space-padded text measures — and wraps — where PowerPoint's does (course decks overlay+ // equation objects on runs of reserved spaces).+ let face_of = |l: &TextLayout, ch: char| {+ l.runs.iter().find(|r| r.cells.iter().any(|&(_, c)| c == ch)).map(|r| r.face.family.clone()).expect("every character is drawn")+ };+ let (p, sh) = setup("日 a ≥", 600.0, 200.0);+ let l = lay(&p, &sh, 600.0, 200.0);+ let latin = face_of(&l, 'a');+ assert_eq!(face_of(&l, ' '), latin, "the space after the Han character stays Latin");+ assert_eq!(face_of(&l, '≥'), latin, "≥ stays Latin after East Asian text");+ let (p, sh) = setup(" 日", 600.0, 200.0);+ let l = lay(&p, &sh, 600.0, 200.0);+ let (p2, sh2) = setup("a", 600.0, 200.0);+ let l2 = lay(&p2, &sh2, 600.0, 200.0);+ assert_eq!(face_of(&l, ' '), face_of(&l2, 'a'), "a leading space takes the Latin typeface too");+}++#[test]+fn the_symbol_font_maps_through_the_adobe_encoding() {+ // The Symbol font's codes are not bullets: the ASCII range and the math operators map to+ // their Unicode equivalents (the published Adobe Symbol encoding), so missing-font text+ // keeps its characters instead of collapsing into bullets.+ assert_eq!(map_symbol_bullet("+", Some("Symbol")), "+");+ assert_eq!(map_symbol_bullet("=", Some("Symbol")), "=");+ assert_eq!(map_symbol_bullet("(", Some("Symbol")), "(");+ assert_eq!(map_symbol_bullet("\u{F02D}", Some("Symbol")), "−");+ assert_eq!(map_symbol_bullet("\u{F0B3}", Some("Symbol")), "≥");+ assert_eq!(map_symbol_bullet("a", Some("Symbol")), "α");+ assert_eq!(map_symbol_bullet("\u{F0D6}", Some("Symbol")), "√");+ assert_eq!(map_symbol_bullet("\u{F0B7}", Some("Symbol")), "•");+ // The bullet fonts keep their look-alikes.+ assert_eq!(map_symbol_bullet("\u{F06C}", Some("Wingdings")), "●");+ assert_eq!(map_symbol_bullet("n", Some("Webdings")), "●");+ assert_eq!(map_symbol_bullet("\u{F0FC}", Some("Wingdings")), "✓");+}++#[test]+fn metafile_symbol_charset_text_falls_back_to_the_bytes_character() {+ // MathType writes equation text in "Times New Roman" with the symbol character set. Where+ // the face has the U+F000+byte glyph it is drawn; where it hasn't (the font is missing),+ // the byte's own character stands in rather than a .notdef box.+ let db = FontDb::global();+ let Some(family) = first_family(LATIN_FAMILIES, |f| f.covers('+') && !f.covers('\u{F02B}')) else {+ eprintln!("skipped: every Latin font maps the symbol range");+ return;+ };+ let face = db.face(&family, "Regular");+ assert_eq!(metafile_char(&face, "Times New Roman", SYMBOL_CHARSET, '\u{F02B}'), '+');+ assert_eq!(metafile_char(&face, "Times New Roman", SYMBOL_CHARSET, '\u{F06E}'), 'n');+ assert_eq!(metafile_char(&face, "Times New Roman", 0, '+'), '+');+ // A symbol family without its font keeps the look-alike mapping.+ assert_eq!(metafile_char(&face, "Symbol", SYMBOL_CHARSET, '\u{F02B}'), '+');+}++#[test]+fn glyph_advances_are_quantized_to_hundredths_of_a_point() {+ // Office measures in hundredths of a point, each glyph's advance rounded down: 17.5625 pt+ // advances 17.56, whole-point advances stay whole, and a space-padded line fits its box by+ // the width PowerPoint saw.+ assert_eq!(glyph_advance(17.5625), 17.56);+ assert_eq!(glyph_advance(23.109375), 23.1);+ assert_eq!(glyph_advance(10.65625), 10.65);+ assert_eq!(glyph_advance(32.0), 32.0);+ assert_eq!(glyph_advance(8.0), 8.0);+}++#[test]+fn single_line_autofit_labels_never_wrap() {+ // spAutoFit 单行标签框(保存高度=一行文字+内边距):度量略有差异时不按框宽+ // 换行——WPS 课件“变式3:”标签被折成两行叠到习题上的实测驱动。+ let (p, mut sh) = setup("变式3:", 95.0, 41.0);+ sh.text_box = true;+ let body = sh.text.as_mut().unwrap();+ body.body.autofit = Some(AutoFit::Shape);+ for para in &mut body.paragraphs {+ for run in &mut para.runs {+ run.props.size = Some(28.0);+ }+ }+ let l = lay(&p, &sh, 95.0, 41.0);+ assert_eq!(l.lines.len(), 1, "单行标签不换行");++ // 两行高的同款框(作者排的就是多行):换行语义不变。+ let (p, mut sh) = setup("变式3:变式3:变式3:", 95.0, 74.0);+ sh.text_box = true;+ let body = sh.text.as_mut().unwrap();+ body.body.autofit = Some(AutoFit::Shape);+ let l = lay(&p, &sh, 95.0, 74.0);+ assert!(l.lines.len() >= 2, "装不下的文字仍换行");+}