mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
M7: edit text: Cyrillic/Greek fallback letters are spaced proportionally (#528)
Replacement text that the line's own (subset) font can't show and WinAnsi can't either goes to the Type 3 fallback. For serif sources that was always Shippori Mincho, which has no Cyrillic or Greek, so e.g. retyping a Bulgarian word in a Garamond line failed with "Japanese fallback font has no glyph for U+0421". BIZ UDMincho and BIZ UDPGothic have the letters. document_font_for_text keeps the style's face when it shows every character and otherwise picks the closest Japanese face that does (Mincho first for serif, then weight). Those faces draw Cyrillic and Greek full-width (1 em, JIS X 0208) with no palt metrics, so fallback glyphs in those blocks are respaced to their ink width plus a 0.05 em side bearing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
0375bd1e28
commit
2d24de1c16
@@ -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 and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese, real Mincho/Gothic fallback outlines matching the requested/source family and available weight (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 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.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
|
||||
|
||||
@@ -582,9 +582,13 @@ fn type3_fallback_glyphs_declare_a_well_formed_d1() {
|
||||
if without_craft_fonts("type3_fallback_glyphs_declare_a_well_formed_d1") {
|
||||
return;
|
||||
}
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, "見本商会 御中").unwrap();
|
||||
let glyphs = fallback_paths(&reopen(&doc));
|
||||
// Cyrillic is respaced (shifted by its side bearing), and its box must move with it.
|
||||
let mut glyphs = Vec::new();
|
||||
for replacement in ["見本商会 御中", "София 2027"] {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
|
||||
glyphs.extend(fallback_paths(&reopen(&doc)));
|
||||
}
|
||||
assert!(!glyphs.is_empty());
|
||||
for glyph in glyphs {
|
||||
let text = String::from_utf8(glyph).unwrap();
|
||||
@@ -602,6 +606,42 @@ fn type3_fallback_glyphs_declare_a_well_formed_d1() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shippori Mincho, the serif fallback, has no Cyrillic: Bulgarian replacement text in a serif
|
||||
/// line takes a fallback face that has every letter instead of failing on the first one.
|
||||
#[test]
|
||||
fn cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters() {
|
||||
if without_craft_fonts("cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters") {
|
||||
return;
|
||||
}
|
||||
let replacement = "София 2027";
|
||||
for paragraph in [false, true] {
|
||||
let mut doc = styled_text_page("ABCDEF+Garamond");
|
||||
if paragraph {
|
||||
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
|
||||
} else {
|
||||
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
|
||||
}
|
||||
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
assert_eq!(lines[0].text, replacement);
|
||||
// Letters are spaced by their shape, not one em each (the faces' full-width Cyrillic).
|
||||
let widths = fallback_widths(&doc);
|
||||
assert!(widths.iter().all(|w| *w > 300.0 && *w < 900.0), "{widths:?}");
|
||||
assert!(widths.windows(2).any(|w| w[0] != w[1]), "proportional: {widths:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/Widths` of the page's Type 3 fallback font, for its Cyrillic letters.
|
||||
fn fallback_widths(doc: &Document) -> Vec<f64> {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(0);
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
|
||||
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
|
||||
let font = font.as_dict().unwrap();
|
||||
let widths = doc.resolve(font.get(b"Widths").unwrap()).as_array().unwrap().iter().filter_map(|w| w.as_f64()).collect::<Vec<_>>();
|
||||
// "София 2027": the first five codes are the letters.
|
||||
widths[..5].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_paragraph_keeps_ideographic_spaces() {
|
||||
if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") {
|
||||
|
||||
+24
-7
@@ -538,32 +538,49 @@ fn pdf_num(v: f64) -> String {
|
||||
fn type3_path(face: &CraftFont, ch: char) -> Result<(Vec<u8>, f64), EditError> {
|
||||
let glyph = japanese_glyph_from(face, ch).map_err(|e| match e {
|
||||
GlyphError::NoFont => no_japanese_font(),
|
||||
GlyphError::Missing => EditError::Invalid(format!("Japanese fallback font has no glyph for U+{:04X}", ch as u32)),
|
||||
GlyphError::Missing => EditError::Invalid(format!("no fallback font has a glyph for U+{:04X}", ch as u32)),
|
||||
GlyphError::TooComplex => EditError::Invalid(format!("Japanese fallback glyph U+{:04X} is too complex", ch as u32)),
|
||||
})?;
|
||||
let (dx, width) = proportional(ch, &glyph);
|
||||
let scale = 1000.0;
|
||||
// d1 is `wx wy llx lly urx ury` (ISO 32000-2 §9.6.4) with a box enclosing the glyph;
|
||||
// Acrobat draws a bullet in place of a glyph whose d1 is malformed.
|
||||
let b = if glyph.contours.is_empty() { [0.0; 4] } else { glyph.bbox };
|
||||
let mut out = format!(
|
||||
"{} 0 {} {} {} {} d1\n",
|
||||
pdf_num(glyph.width * scale),
|
||||
pdf_num((b[0] * scale).floor()),
|
||||
pdf_num(width * scale),
|
||||
pdf_num(((b[0] + dx) * scale).floor()),
|
||||
pdf_num((b[1] * scale).floor()),
|
||||
pdf_num((b[2] * scale).ceil()),
|
||||
pdf_num(((b[2] + dx) * scale).ceil()),
|
||||
pdf_num((b[3] * scale).ceil())
|
||||
)
|
||||
.into_bytes();
|
||||
for contour in glyph.contours {
|
||||
let Some(first) = contour.first() else { continue };
|
||||
out.extend_from_slice(format!("{} {} m\n", pdf_num(first[0] * scale), pdf_num(first[1] * scale)).as_bytes());
|
||||
out.extend_from_slice(format!("{} {} m\n", pdf_num((first[0] + dx) * scale), pdf_num(first[1] * scale)).as_bytes());
|
||||
for p in contour.iter().skip(1) {
|
||||
out.extend_from_slice(format!("{} {} l\n", pdf_num(p[0] * scale), pdf_num(p[1] * scale)).as_bytes());
|
||||
out.extend_from_slice(format!("{} {} l\n", pdf_num((p[0] + dx) * scale), pdf_num(p[1] * scale)).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"h\n");
|
||||
}
|
||||
out.extend_from_slice(b"f\n");
|
||||
Ok((out, glyph.width))
|
||||
Ok((out, width))
|
||||
}
|
||||
|
||||
/// Side bearing (em) given to a respaced glyph on each side.
|
||||
const SIDE_BEARING: f64 = 0.05;
|
||||
|
||||
/// Shift and advance for a fallback glyph. Japanese faces draw Cyrillic and Greek full-width
|
||||
/// (one em each, as in JIS X 0208) and have no proportional (`palt`) metrics for them, so a
|
||||
/// word set that way reads as letter-spaced. Those letters get their ink width plus a side
|
||||
/// bearing instead; everything else keeps the face's own advance.
|
||||
fn proportional(ch: char, glyph: &pdfcraft_fonts::GlyphOutline) -> (f64, f64) {
|
||||
let respace = matches!(ch, '\u{0370}'..='\u{03FF}' | '\u{0400}'..='\u{052F}' | '\u{1F00}'..='\u{1FFF}');
|
||||
let ink = glyph.bbox[2] - glyph.bbox[0];
|
||||
if !respace || glyph.contours.is_empty() || !(ink > 0.0 && ink < glyph.width) {
|
||||
return (0.0, glyph.width);
|
||||
}
|
||||
(SIDE_BEARING - glyph.bbox[0], ink + 2.0 * SIDE_BEARING)
|
||||
}
|
||||
|
||||
fn unicode_hex(ch: char) -> String {
|
||||
|
||||
Reference in New Issue
Block a user