M7: report Japanese Type 3 fallback font names (#387)

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
midasdfandBrandon Thomas authored and GitHub committed 2026-10-08 21:03:16 -04:00
1 parent dc356f0dfc
commit 438a269bac
6 files changed
+97 -11

No files matched your search

+1 -1
View File
@@ -998,7 +998,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
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 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; otherwise the line is set in Helvetica (the result shows the font used). 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 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; otherwise a standard font or the Japanese Type 3 fallback is used (the result shows the font used). The Japanese fallback does not yet preserve the source's family or weight. 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 },
+24
View File
@@ -304,6 +304,30 @@ fn japanese_line_uses_unicode_type3_fallback() {
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, replacement);
}
#[test]
fn japanese_fallback_reports_the_face_after_save_and_reopen() {
if without_craft_fonts("japanese_fallback_reports_the_face_after_save_and_reopen") {
return;
}
let face = pdfcraft_fonts::document_japanese_font().unwrap();
let expected = format!("{}-{}", face.family, face.style).replace(' ', "");
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "見本商会 御中";
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
for d in [&doc, &reopen(&doc)] {
let lines = text::text_lines(d, 0).unwrap();
assert_eq!(lines[0].text, replacement);
assert_eq!(lines[0].base_font, expected, "report the real fallback face, not an empty BaseFont");
assert_eq!(text::text_blocks(d, 0).unwrap()[0].base_font, expected);
}
}
}
#[test]
fn japanese_paragraph_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") {
+11 -2
View File
@@ -27,7 +27,7 @@ pub struct TextLine {
pub text: String,
/// Its box in user space.
pub rect: [f64; 4],
/// The font's resource name and `/BaseFont`, and its size in text space.
/// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space.
pub font: String,
pub base_font: String,
pub size: f64,
@@ -569,7 +569,8 @@ fn no_japanese_font() -> EditError {
}
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Type3Fallback, EditError> {
let family = pdfcraft_fonts::document_japanese_font().ok_or_else(no_japanese_font)?.family;
let face = pdfcraft_fonts::document_japanese_font().ok_or_else(no_japanese_font)?;
let family = face.family;
let mut chars = Vec::new();
for ch in text.chars() {
if !chars.contains(&ch) {
@@ -614,6 +615,14 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Ty
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("PCJapanese"));
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(' ', "")));
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(face.family.as_bytes().to_vec())));
descriptor.set(b"Flags".to_vec(), Object::Int(if face.family.contains("Mincho") { 6 } else { 4 }));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
// PDF 1.7 tables 5.9 and 5.19: a Type 3 descriptor is indirect; Ascent/Descent may be omitted.
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)]));
font.set(
b"FontMatrix".to_vec(),
+7
View File
@@ -30,6 +30,13 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
`japanese_glyph` returns `GlyphError::NoFont` and the editor reports a clear error.
- `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`.
Generated Japanese Type 3 fonts record the fallback face's family and style in an indirect
`FontDescriptor`. `pdf::Metrics::base_font` uses its `FontName` when the Type 3 dictionary has
no `BaseFont`, so text inspection and edit-tool results identify the fallback after save/reopen.
The descriptor does not change glyph programs or replace the Type 3 `FontBBox` metrics when
`Ascent`/`Descent` are omitted (PDF Reference 1.7, tables 5.9 and 5.19). Matching the source
font's family and weight remains unsupported.
wasm32 builds embed only BIZ UDPGothic Regular and any `Arab` or `Telu` face, to keep the web build small: the web build
currently has no Chinese face, so Chinese there still shows the replacement glyph.
Font files are never
+52 -6
View File
@@ -47,7 +47,7 @@ pub struct Metrics {
pub composite: bool,
/// Code → Unicode text.
unicode: HashMap<u32, String>,
/// `/BaseFont`, and whether it is a subset (`ABCDEF+Name`: other glyphs are missing).
/// `/BaseFont` (or Type 3 descriptor's `/FontName`), and whether it is a subset (`ABCDEF+Name`: other glyphs are missing).
pub base_font: String,
pub subset: bool,
/// Whether the PDF identifies this face as bold or italic. These come from the
@@ -162,7 +162,16 @@ impl Metrics {
pub fn from_dict(doc: &Document, font: &Dict) -> Self {
let mut m = Self::read_metrics(doc, font);
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned();
let base = font
.name(b"BaseFont")
.map(|name| String::from_utf8_lossy(name).into_owned())
.or_else(|| {
(font.name(b"Subtype") == Some(b"Type3"))
.then(|| dict(doc, font.get(b"FontDescriptor")))
.flatten()
.and_then(|d| d.name(b"FontName").map(|name| String::from_utf8_lossy(name).into_owned()))
})
.unwrap_or_default();
m.subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
let (bold, italic) = style_from_name(&base);
m.bold |= bold;
@@ -263,11 +272,18 @@ impl Metrics {
let weight = d.get(b"FontWeight").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0);
m.italic |= flags & 64 != 0 || angle.abs() > 0.1;
m.bold |= flags & 262_144 != 0 || weight >= 600.0;
let a = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
let de = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
// Type 3 descriptors may omit these metrics; the glyph-space FontBBox still applies.
let ascent = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64());
let descent = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64());
// Fonts often claim 0; never shrink the glyph box below a sensible minimum.
m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 };
m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 };
if ascent.is_some() || subtype != b"Type3" {
let a = ascent.unwrap_or(0.0) / 1000.0;
m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 };
}
if descent.is_some() || subtype != b"Type3" {
let de = descent.unwrap_or(0.0) / 1000.0;
m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 };
}
}
m
}
@@ -505,6 +521,36 @@ mod tests {
d
}
#[test]
fn type3_descriptor_names_the_font_without_changing_its_metrics() {
let mut doc = Document::new_empty();
let mut f = font(
&mut doc,
vec![
("Subtype", Object::name("Type3")),
(
"FontMatrix",
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
),
("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])),
],
);
let before = Metrics::from_dict(&doc, &f);
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular"));
descriptor.set(b"Flags".to_vec(), Object::Int(6));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
let reference = doc.add(Object::Dict(descriptor));
f.set(b"FontDescriptor".to_vec(), Object::Ref(reference));
let after = Metrics::from_dict(&doc, &f);
assert_eq!(after.base_font, "ExampleMincho-Regular");
assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale));
assert!(!after.bold && !after.italic && !after.subset);
f.set(b"BaseFont".to_vec(), Object::name("ExplicitName"));
assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence");
}
#[test]
fn simple_fonts_decode_and_encode_through_their_encoding() {
let mut doc = Document::new_empty();
+2 -2
View File
@@ -1806,8 +1806,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
evidence = ["crates/edit/src/tests.rs::japanese_paragraph_keeps_ideographic_spaces", "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"]
notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin; U+3000 ideographic spaces are preserved (including paragraph edges and repeated spaces); Japanese font-style fallback remains limited (#184)"
evidence = ["crates/edit/src/tests.rs::japanese_fallback_reports_the_face_after_save_and_reopen", "crates/edit/src/tests.rs::japanese_paragraph_keeps_ideographic_spaces", "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"]
notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin; U+3000 ideographic spaces are preserved (including paragraph edges and repeated spaces); Japanese Type 3 fallback reports its face through FontDescriptor/FontName after editing and save/reopen; source family/weight matching remains limited (#184)"
[[feature]]
id = "edit.text-move-resize"