diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 59ecf0425..290fea751 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1287,7 +1287,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2c87162d5..380edffaf 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2594,6 +2594,59 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +/// #2593: a paragraph whose own font cannot show a character is written from PdfCraft's fallback +/// faces. The Cyrillic the craft-fonts faces lack (Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ) comes from the +/// bundled Inter face, so the edit is not refused even without `CRAFT_FONTS_DIR`, and the words +/// survive a save and a reopen. (`crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face` +/// pins which face drew them.) +#[test] +fn editing_uzbek_and_kazakh_cyrillic_through_tools() { + let dir = workdir("cyrillic-fallback"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET"; + std::fs::write(dir.join("cy.pdf"), win_ansi_paragraph_pdf(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "cy.pdf" }))["doc"].as_u64().unwrap(); + for word in ["қизил", "әже"] { + let text = format!("The quick brown fox jumps over the lazy dog {word}\nand keeps running through the long field."); + let edited = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + let paragraph = edited["paragraph"]["text"].as_str().unwrap(); + assert!(paragraph.starts_with("The quick brown fox") && paragraph.ends_with("long field."), "{word}: {edited}"); + assert!(paragraph.contains(word), "{word}: {edited}"); + assert!(page_text(&mut a, doc)[0].contains(word), "{word}: the edited page reads it back"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + } + // Keep the Kazakh edit, save it, and read it back from the file on disk. + let text = "The quick brown fox jumps over the lazy dog әже\nand keeps running through the long field."; + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "cy-out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "cy-out.pdf" }))["doc"].as_u64().unwrap(); + assert!(page_text(&mut a, saved)[0].contains("әже"), "the saved file reads it back"); +} + +/// One Letter page whose Helvetica is not embedded (WinAnsi), with `body` as its content stream. +fn win_ansi_paragraph_pdf(body: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + /// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle /// piece and its operator starts the last one. fn split_streams_pdf() -> Vec { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c6..c459a3a73 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1172,6 +1172,44 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { } } +/// #2593: the craft-fonts faces are Japanese, and part of the Cyrillic (Uzbek қ ғ ҳ, Kazakh ә ө ү +/// ұ һ) is in none of them. The bundled Inter face has it, so the replacement is written instead of +/// being refused, and a build without craft-fonts can do it too. +#[test] +fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() { + for replacement in ["The quick brown fox jumps over the lazy dog қизил", "әже", "ўзбекча ғўза"] { + for paragraph in [false, true] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = + if paragraph { text::replace_block(&mut doc, 0, 0, replacement) } else { text::replace_line(&mut doc, 0, 0, replacement) }.unwrap(); + assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCLat"), "{replacement}: {content}"); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines[0].text, replacement); + assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew"); + } + } +} + +/// The Japanese faces keep first refusal for the Cyrillic they do carry, so Russian text keeps the +/// face it was drawn with before the bundled Latin face was added. +#[test] +fn cyrillic_the_japanese_faces_carry_keeps_them() { + if without_craft_fonts("cyrillic_the_japanese_faces_carry_keeps_them") { + return; + } + let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap(); + if pdfcraft_fonts::japanese_glyph_from(face, 'ф').is_err() { + eprintln!("skipping: the Japanese fallback face has no Cyrillic"); + return; + } + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = text::replace_line(&mut doc, 0, 0, "Привет, world").unwrap(); + let expected = format!("{} Type3", face.family); + assert_eq!(edit.substituted.as_deref(), Some(expected.as_str())); +} + #[test] fn japanese_paragraph_uses_unicode_type3_fallback() { if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") { @@ -4717,16 +4755,18 @@ fn a_new_character_comes_from_the_source_fonts_own_embedded_program() { #[test] fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() { + let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); + // The subset's own program is not trusted for a character: the bundled Inter face draws it + // through a generated font instead (the subset may map a code whose outline was dropped). + let edit = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()).unwrap(); + assert_eq!(edit.substituted.as_deref(), Some("Inter Type3")); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "Stable tőxt"); + // A character no fallback face has is refused, named, and left as it was. let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); let before = page_content_bytes(&doc, 0); - let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()); - if pdfcraft_fonts::document_japanese_font().is_some() { - eprintln!("built with craft-fonts: the fallback font draws the new character"); - assert!(result.is_ok(), "{result:?}"); - return; - } + let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable กtext"), &text::BlockStyle::default()); let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") }; - assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); + assert!(msg.contains("ก (U+0E01)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); assert_eq!(page_content_bytes(&doc, 0), before); } diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 267259217..279036155 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1448,12 +1448,28 @@ fn needs_type3(text: &str) -> bool { const JAPANESE_FONT_NAME: &str = "PCJapanese"; /// The `/Name` of the Type 3 fonts generated for Hebrew text. const HEBREW_FONT_NAME: &str = "PCHebrew"; +/// The `/Name` of the Type 3 fonts generated from the bundled Latin (Inter) face. +const LATIN_FONT_NAME: &str = "PCLatin"; -/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than -/// one of them is split across several, which read back as one line (see [`same_font`]). +/// Whether `font` is one of those generated fonts (Latin, Japanese or Hebrew): a replacement longer +/// than one of them is split across several, which read back as one line (see [`same_font`]). fn is_type3_fallback(font: &Dict) -> bool { font.name(b"Subtype") == Some(b"Type3") - && font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes()) + && font + .name(b"Name") + .is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes()) +} + +/// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the +/// Japanese ones. +fn fallback_names(face: &CraftFont, hebrew: bool) -> (&'static str, &'static str) { + if hebrew { + (HEBREW_FONT_NAME, "PCHe") + } else if face.family == "Inter" { + (LATIN_FONT_NAME, "PCLat") + } else { + (JAPANESE_FONT_NAME, "PCJp") + } } /// Whether a run shown in `font` (a resource name, and the Japanese or Hebrew fallback face it subsets, if @@ -1485,7 +1501,8 @@ const MAX_TYPE3_GLYPHS: usize = 240; struct Type3Fallback { /// The family its glyphs come from ("Shippori Mincho"). family: &'static str, - /// The fonts' resource names in order; the first is `PCJp` (`PCHe` for Hebrew text). + /// The fonts' resource names in order; the first is `PCJp` (`PCLat` for the bundled Latin + /// face, `PCHe` for Hebrew text). names: Vec, /// Every unique character with its advance (em), in order of first appearance. widths: Vec<(char, f64)>, @@ -1559,11 +1576,19 @@ fn unicode_hex(ch: char) -> String { encoded.iter().map(|u| format!("{u:04X}")).collect() } -/// No font this build has can draw `text`. Only Japanese text is about the Japanese face: other +/// The same outline for a face that may simply lack the glyph: `None` then, so the caller can try +/// the next face (and report the character once none of them has it). +fn type3_glyph(face: &CraftFont, ch: char) -> Result, f64)>, EditError> { + match japanese_glyph_from(face, ch) { + Err(GlyphError::Missing) => Ok(None), + _ => type3_path(face, ch).map(Some), + } +} + +/// No face this build has can draw `text`. Only Japanese text is about the Japanese face: other /// characters are missing from the paragraph's font, and choosing a font that has them works too. fn no_fallback_font(text: &str) -> EditError { - let japanese = |c: char| matches!(u32::from(c), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF); - if text.chars().any(japanese) { + if text.chars().any(needs_japanese) { return no_japanese_font(); } let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() }; @@ -1575,6 +1600,28 @@ fn no_fallback_font(text: &str) -> EditError { )) } +/// The character the craft-fonts Japanese faces are for: kana, CJK ideographs and their compatibility +/// and full-width forms. +fn needs_japanese(ch: char) -> bool { + matches!(u32::from(ch), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF) +} + +/// The chosen face has no outline for `ch`, and neither has any other: say which character it is and +/// what the writer can do about it. A CJK character without a Japanese face in the build is about +/// that build input, which is the one answer that changes it. +fn no_glyph_for(face: &CraftFont, ch: char) -> EditError { + if needs_japanese(ch) && pdfcraft_fonts::document_japanese_font().is_none() { + return no_japanese_font(); + } + EditError::Invalid(format!( + "the {} fallback font has no glyph for {ch} (U+{:04X}), and no fallback face covers the text; \ + choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ + PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", + face.family, + u32::from(ch) + )) +} + /// This build has no Japanese face to draw replacement text with. fn no_japanese_font() -> EditError { EditError::Invalid( @@ -1599,12 +1646,15 @@ fn no_hebrew_font() -> EditError { } /// The faces a Type 3 replacement may draw `text` with, best first. Text with Hebrew takes the -/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese -/// faces for anything it lacks; other text takes the Japanese faces, as before. +/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese faces +/// and the bundled Latin one for anything it lacks; other text takes the Japanese faces, and the +/// bundled Latin face behind them, which covers the Latin, Greek and Cyrillic letters they lack +/// (#2593: the Japanese faces have no қ, ғ, ҳ, ә, ө, ү, ұ or һ). fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<(Vec<&'static CraftFont>, bool), EditError> { let japanese = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold); + let latin = pdfcraft_fonts::document_latin_fonts(bold); if !text.chars().any(crate::bidi::is_hebrew) { - return Ok((japanese, false)); + return Ok((japanese.into_iter().chain(latin).collect(), false)); } // Points are drawn on their letter with the font's positioning, which a one-glyph-per- // character Type 3 font can't do; refuse rather than print them beside the letter. @@ -1612,7 +1662,7 @@ fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<( return Err(EditError::Invalid("Hebrew points (niqqud) can't be written into a PDF yet; type the text without them".into())); } let hebrew = pdfcraft_fonts::document_hebrew_font(bold).ok_or_else(no_hebrew_font)?; - Ok((std::iter::once(hebrew).chain(japanese).collect(), true)) + Ok((std::iter::once(hebrew).chain(japanese).chain(latin).collect(), true)) } fn type3_font( @@ -1646,8 +1696,16 @@ fn type3_font( let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); let mut glyphs = Vec::with_capacity(chars.len()); for (i, ch) in chars.into_iter().enumerate() { - // Hebrew text: the first face with the glyph, else the Hebrew face's error. - let (path, width) = others.iter().fold(type3_path(face, ch), |found, f| found.or_else(|e| type3_path(f, ch).map_err(|_| e)))?; + // The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew + // face lacks from the next face that has it); a character no face draws is reported once. + let mut drawn = type3_glyph(face, ch)?; + for f in others.iter().copied() { + if drawn.is_some() { + break; + } + drawn = type3_glyph(f, ch)?; + } + let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) }; let code = u8::try_from((i % per_font).saturating_add(1)) .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; glyphs.push(Type3Glyph { code, ch, path, width }); @@ -1671,7 +1729,7 @@ fn type3_font( } /// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource -/// name (`PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the +/// name (`PCLat`, `PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the /// outlines were read already. fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, hebrew: bool, glyphs: Vec) -> String { let last_char = glyphs.len(); @@ -1702,7 +1760,8 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, he let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type3")); - font.set(b"Name".to_vec(), Object::name(if hebrew { HEBREW_FONT_NAME } else { JAPANESE_FONT_NAME })); + let (font_name, prefix) = fallback_names(face, hebrew); + font.set(b"Name".to_vec(), Object::name(font_name)); let mut descriptor = Dict::new(); descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', ""))); @@ -1722,7 +1781,6 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, he font.set(b"Encoding".to_vec(), Object::Dict(encoding)); font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref)); - let prefix = if hebrew { "PCHe" } else { "PCJp" }; let mut name = String::from(prefix); let mut suffix = 0usize; while fonts_res.contains(name.as_bytes()) { diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index 099179cf2..50e05adf4 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -138,6 +138,24 @@ pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'stati document_faces(CRAFT_FONTS, serif, bold) } +/// The scripts the bundled Inter faces are for. +static LATIN_SCRIPTS: &[&str] = &["Latn", "Cyrl", "Grek"]; + +/// The bundled Latin document faces (`assets/fonts`, OFL): every build has them, unlike +/// craft-fonts. The Japanese faces carry only part of the Cyrillic (`привет` yes, Uzbek `қизил` +/// and Kazakh `әже` no), so letters like қ, ғ, ҳ, ә, ө, ү, ұ and һ have no face at all without +/// these. +static INTER_REGULAR_FACE: CraftFont = + CraftFont { family: "Inter", style: "Regular", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf") }; +static INTER_SEMIBOLD_FACE: CraftFont = + CraftFont { family: "Inter", style: "SemiBold", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf") }; + +/// The face for Latin, Greek and Cyrillic text written into PDFs: the bundled Inter Regular, or +/// SemiBold for bold text. Present in every build, crafted ones included. +pub fn document_latin_fonts(bold: bool) -> Vec<&'static CraftFont> { + vec![if bold { &INTER_SEMIBOLD_FACE } else { &INTER_REGULAR_FACE }] +} + fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> { document_faces(faces, serif, bold).into_iter().next() } @@ -296,6 +314,20 @@ mod tests { assert_eq!(document_hebrew_font(false).is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Hebr"))); } + /// The bundled Inter faces carry the Latin, Greek and Cyrillic letters the craft-fonts faces + /// lack: Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ, Ukrainian ї є ґ, Greek. This one runs with or without + /// CRAFT_FONTS_DIR (#2593). + #[test] + fn latin_faces_carry_the_letters_the_japanese_faces_lack() { + for face in document_latin_fonts(false).into_iter().chain(document_latin_fonts(true)) { + for ch in ['қ', 'ғ', 'ҳ', 'ә', 'ө', 'ү', 'ұ', 'һ', 'ї', 'є', 'ґ', 'ё', 'α', 'β', 'γ', '≥', '≈'] { + assert!(crate::japanese_glyph_from(face, ch).is_ok(), "{} has no {ch}", face.name()); + } + } + assert_eq!(document_latin_fonts(false)[0].style, "Regular"); + assert_eq!(document_latin_fonts(true)[0].style, "SemiBold"); + } + #[test] fn craft_fonts_are_optional_and_consistent() { // Holds with or without CRAFT_FONTS_DIR. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a1f2ff277..a1f2ffb22 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -19,8 +19,8 @@ mod std14; mod std14_metrics; pub use craft::{ CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style, - document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts, - ui_telugu_fonts, ui_thai_fonts, + document_japanese_fonts_for_style, document_latin_fonts, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, + ui_japanese_fonts, ui_telugu_fonts, ui_thai_fonts, }; pub use script::{ GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline, diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index e8bb03b9a..be9a34edd 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -24,7 +24,11 @@ page. Select **Keep source fonts** to preserve each run's original face. A match completes its subsets automatically, including new Chinese characters. When the PDF itself embeds the complete program of a paragraph's font (not a subset) and its licence allows editable embedding, a character the font's encoding can't show is drawn with that same program. A -character no available face has is refused with an error that names it. Choosing another face +character no available face has is refused with an error that names it, and a CJK character +without a craft-fonts build says so. Replacement text takes the craft-fonts Japanese faces when +they can draw all of it, and the bundled Inter face (`assets/fonts`) for the Latin, Greek and +Cyrillic letters they lack (Uzbek `қизил`, Kazakh `әже`): that face needs no build input, so +these work in every build, the web one included. Choosing another face is explicit. Fonts with restricted embedding, unsupported collections, excluded families or no open-licence declaration are refused. Interface fallback fonts are never exported. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad00b0115..229ede31d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,8 +1866,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row"] -notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools"] +notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow"