Merge PR #2609: fix(edit): draw the Cyrillic the Japanese faces lack with the bundled Inter face (#2593)

parity/acrobat-features.toml: edit.edit-text evidence and notes take both sides' additions
This commit is contained in:
aiongg
2026-10-22 01:45:35 +00:00
8 changed files with 216 additions and 29 deletions
+47 -7
View File
@@ -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);
}
+74 -16
View File
@@ -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<String>,
/// 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<Option<(Vec<u8>, 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<Type3Glyph>) -> 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()) {