M7: shard Japanese Type 3 fallback fonts across large edits (#958)

A Japanese replacement with more than 240 unique characters was refused. One
generated Type 3 font holds at most 240 glyphs, so the text is now split into
several fonts, each within that bound. The font changes only where the text
moves to another font, and a single font gets the same operators as before.

The reader joins runs across the subset fonts of one face, only for fonts
written by this code (Type 3, /Name PCJapanese). Other documents are unchanged.

A paragraph whose move or width is refused no longer writes its fallback fonts
before the refusal, so the document is left as it was.

Co-authored-by: Claude Haiku 5.5 <[email protected]>
This commit is contained in:
cyfung1031andClaude Haiku 5.5 authored and GitHub committed 2026-10-11 13:16:25 -04:00
1 parent 66edead348
commit fb3fa5de57
2 files changed
+352 -60

No files matched your search

+174
View File
@@ -676,6 +676,180 @@ fn japanese_paragraph_uses_unicode_type3_fallback() {
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
}
/// The first `n` Japanese characters, in code point order, that every craft-fonts face can draw, so
/// whichever face an edit picks has them all. Without craft-fonts there are no faces, every character
/// passes, and the text is still long.
fn covered_japanese_chars(n: usize) -> String {
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
(0x4E00..0xA000)
.filter_map(char::from_u32)
.filter(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_ok()))
.take(n)
.collect()
}
/// The page's Type 3 fallback fonts (`PCJp`, `PCJp1`, ...) by resource name.
fn fallback_fonts(doc: &Document) -> Vec<(Vec<u8>, Dict)> {
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());
fonts
.as_dict()
.unwrap()
.iter()
.filter(|(name, _)| name.starts_with(b"PCJp"))
.map(|(name, font)| (name.clone(), doc.resolve(font).as_dict().unwrap().clone()))
.collect()
}
/// More unique fallback glyphs than one Type 3 font holds (240) are split across several generated
/// fonts, each within that limit, and the text still reads back as one line.
#[test]
fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts() {
let replacement = covered_japanese_chars(300);
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
if without_craft_fonts("japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts") {
// Without craft-fonts the long text gets the clear error, not the old limit message.
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
return;
}
text::replace_line(&mut doc, 0, 0, &replacement).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1, "one line: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(lines[0].text, replacement);
let fonts = fallback_fonts(&reopened);
assert_eq!(fonts.len(), 2, "300 glyphs take a 240-glyph font and a 60-glyph font");
let mut glyphs = 0;
for (name, font) in &fonts {
let procs = reopened.resolve(font.get(b"CharProcs").unwrap());
let count = procs.as_dict().unwrap().len();
let last = font.get(b"LastChar").and_then(|o| o.as_int()).unwrap_or(i64::MAX);
assert!(count <= 240 && last <= 240, "{name:?}: {count} glyphs, LastChar {last}");
glyphs += count;
}
assert_eq!(glyphs, 300);
}
/// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the
/// document nor a snapshot taken before the edit, which is what undo restores.
#[test]
fn japanese_replacement_missing_a_glyph_after_240_changes_nothing() {
if without_craft_fonts("japanese_replacement_missing_a_glyph_after_240_changes_nothing") {
return;
}
// The first CJK character that no face draws: the 242nd unique character, in a second font.
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
let missing = (0x4E00u32..0x9FFF)
.filter_map(char::from_u32)
.find(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_err()))
.expect("some CJK character is outside every face");
let replacement = format!("{}{missing}", covered_japanese_chars(241));
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let snapshot = doc.clone();
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
assert!(err.contains(&format!("U+{:04X}", missing as u32)), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved);
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), saved);
}
/// Runs that change font move nothing: with two glyphs per font the text reads back the same, in the
/// same box as with one font, and the font is switched only where the text moves to another font.
#[test]
fn japanese_fallback_split_across_fonts_keeps_order_and_positions() {
if without_craft_fonts("japanese_fallback_split_across_fonts_keeps_order_and_positions") {
return;
}
// 見 and 商 are in the first font, 御 in the second: runs [見商], [御], [見], three switches.
let replacement = "見商御見";
let mut whole = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let mut split = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut whole, 0, 0, replacement, 240).unwrap();
text::replace_line_with_limit(&mut split, 0, 0, replacement, 2).unwrap();
let (whole, split) = (reopen(&whole), reopen(&split));
assert_eq!(fallback_fonts(&whole).len(), 1);
assert_eq!(fallback_fonts(&split).len(), 2);
let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap());
assert_eq!(many.len(), 1, "one line: {:?}", many.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(many[0].text, replacement);
for (a, b) in one[0].rect.iter().zip(many[0].rect.iter()) {
assert!((a - b).abs() < 1e-6, "box {:?} vs {:?}", one[0].rect, many[0].rect);
}
// One `Tf` per run plus the page's own switch to F2 and the switch back after the text.
let content = String::from_utf8_lossy(&page_content_bytes(&split, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 5, "{content}");
let content = String::from_utf8_lossy(&page_content_bytes(&whole, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 3, "{content}");
}
/// Only a change of font gets a `Tf`: alternating characters with one glyph per font switch at every
/// character, and a run of one font's characters switches nowhere.
#[test]
fn japanese_fallback_switches_font_only_between_runs() {
if without_craft_fonts("japanese_fallback_switches_font_only_between_runs") {
return;
}
let alternating = "見商".repeat(500);
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut doc, 0, 0, &alternating, 1).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, alternating);
let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 1000 + 2, "1000 runs, the F2 switch before and after");
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut doc, 0, 0, "見見見", 1).unwrap();
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 1 + 1 + 1, "{content}");
}
/// A paragraph set in several fallback fonts reads back as one block with the same text, and it
/// wraps across lines.
#[test]
fn japanese_paragraph_split_across_fonts_reads_back_as_one_block() {
if without_craft_fonts("japanese_paragraph_split_across_fonts_reads_back_as_one_block") {
return;
}
// Spaces are glyphs too, so two glyphs per font needs several fonts; the narrow box wraps the text.
let replacement = "見 商 御 見 商";
let style = text::BlockStyle { width: Some(40.0), ..Default::default() };
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::rewrite_block_with_limit(&mut doc, 0, 0, Some(replacement), &style, 2).unwrap();
let reopened = reopen(&doc);
assert!(fallback_fonts(&reopened).len() >= 2);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::<Vec<_>>());
assert_eq!(blocks[0].text, replacement);
assert!(blocks[0].lines.len() > 1, "wrapped to {} line(s)", blocks[0].lines.len());
}
/// A paragraph whose move or width is refused (not finite, or drawn in a space it cannot be moved in)
/// leaves the document as it was: the fallback fonts are written only once the edit is accepted.
#[test]
fn japanese_paragraph_refused_before_its_fallback_fonts_are_written() {
if without_craft_fonts("japanese_paragraph_refused_before_its_fallback_fonts_are_written") {
return;
}
let plain = "BT /F2 12 Tf 72 700 Td (ab) Tj ET";
let cases = [
(plain, text::BlockStyle { offset: Some([f64::NAN, 0.0]), ..Default::default() }, "can't be moved that far"),
(plain, text::BlockStyle { width: Some(f64::INFINITY), ..Default::default() }, "width must be a number"),
(
"0 0 0 0 0 0 cm BT /F2 12 Tf 72 700 Td (ab) Tj ET",
text::BlockStyle { offset: Some([5.0, 0.0]), ..Default::default() },
"can't be moved in",
),
];
for (content, style, message) in cases {
let mut doc = text_page(content);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let err = text::rewrite_block(&mut doc, 0, 0, Some("見本商会 御中"), &style).unwrap_err().to_string();
assert!(err.contains(message), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "{message}: the document changed");
}
}
/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a
/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1`
/// has the wrong operand count.
+178 -60
View File
@@ -38,6 +38,8 @@ pub struct TextLine {
/// Whether new text in this font can only be shown by substituting another font (no
/// Unicode mapping for the line, so nothing could be reused).
pub decodable: bool,
/// The Japanese fallback face the font subsets, when the line is in one (see [`same_font`]).
face: Option<String>,
/// The content stream piece the line starts in, and its operators (indices into the page's
/// streams parsed joined).
stream: usize,
@@ -142,6 +144,8 @@ struct Ts {
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
fill: Vec<Op>,
font: Option<(Vec<u8>, Rc<Metrics>)>,
/// The Japanese fallback face the font subsets, when the font is one (see [`is_japanese_fallback`]).
face: Option<String>,
size: f64,
char_spacing: f64,
word_spacing: f64,
@@ -265,6 +269,7 @@ struct Shown {
bt: usize,
font: Vec<u8>,
base_font: String,
face: Option<String>,
size: f64,
bold: bool,
italic: bool,
@@ -285,6 +290,7 @@ impl Carry {
ctm: Matrix::IDENTITY,
fill: Vec::new(),
font: None,
face: None,
size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
@@ -304,7 +310,7 @@ fn interpret(
doc: &Document,
ops: &[Op],
fonts_res: &Dict,
cache: &mut HashMap<Vec<u8>, Rc<Metrics>>,
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
carry: &mut Carry,
mut invoked: Option<&mut Vec<Invocation>>,
) -> Vec<Shown> {
@@ -341,16 +347,14 @@ fn interpret(
b"Tf" => {
ts.size = op.num(1).unwrap_or(ts.size);
if let Some(name) = op.name(0) {
let m = cache.entry(name.to_vec()).or_insert_with(|| {
Rc::new(
fonts_res
.get(name)
.and_then(|f| doc.resolve(f).as_dict().cloned())
.map(|d| Metrics::from_dict(doc, &d))
.unwrap_or_else(Metrics::fallback),
)
let (m, face) = cache.entry(name.to_vec()).or_insert_with(|| {
let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned());
let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d));
let face = dict.filter(is_japanese_fallback).map(|_| metrics.base_font.clone());
(Rc::new(metrics), face)
});
ts.font = Some((name.to_vec(), m.clone()));
ts.face = face.clone();
}
}
b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0),
@@ -460,6 +464,7 @@ fn interpret(
bt,
font: name,
base_font: m.base_font.clone(),
face: ts.face.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
@@ -495,7 +500,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let joins = last.is_some_and(|(piece, bt, base, end, size)| {
piece == si
&& bt == s.bt
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
&& lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))
&& lines.last().is_some_and(|l| {
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
})
@@ -524,6 +529,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
italic: s.italic,
color: fill_color(&s.state.fill),
decodable: s.decodable,
face: s.face.clone(),
stream: si,
ops: vec![s.op],
origin: Origin {
@@ -551,7 +557,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) {
let joins = last.is_some_and(|(bt, base, end, size)| {
bt == s.bt
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
&& lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))
&& lines
.last()
.is_some_and(|l| (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color)
@@ -580,6 +586,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
italic: s.italic,
color: fill_color(&s.state.fill),
decodable: s.decodable,
face: s.face.clone(),
stream,
ops: vec![s.op],
origin: Origin {
@@ -713,8 +720,8 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
Ok(lines)
}
/// Text → the bytes that show it in the chosen font.
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
fn is_win_ansi_char(c: char) -> bool {
c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some()
@@ -724,6 +731,21 @@ fn needs_type3(text: &str) -> bool {
text.chars().any(|c| !is_win_ansi_char(c))
}
/// The `/Name` of the Type 3 fonts generated for Japanese text.
const JAPANESE_FONT_NAME: &str = "PCJapanese";
/// Whether `font` is one of those generated fonts: a replacement longer than one of them is split
/// across several, which read back as one line (see [`same_font`]).
fn is_japanese_fallback(font: &Dict) -> bool {
font.name(b"Subtype") == Some(b"Type3") && font.name(b"Name") == Some(JAPANESE_FONT_NAME.as_bytes())
}
/// Whether a run shown in `font` (a resource name, and the Japanese fallback face it subsets, if
/// any) continues `line`: the same font resource, or another subset of the same face.
fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool {
line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face)
}
fn source_family(base_font: &str) -> crate::added::Family {
let name = base_font.to_ascii_lowercase();
if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
@@ -741,12 +763,26 @@ const SUBSTITUTE_NAME: &str = "PCEdHelv";
const SUBSTITUTE: &[u8] = SUBSTITUTE_NAME.as_bytes();
const MAX_TYPE3_GLYPHS: usize = 240;
/// The generated fonts for one replacement: one per run of at most `max_glyphs` unique characters,
/// in order of first appearance.
#[derive(Clone)]
struct Type3Fallback {
name: String,
/// The family its glyphs come from ("Shippori Mincho").
family: &'static str,
codes: Vec<(char, u8, f64)>,
/// The fonts' resource names in order; the first is `PCJp`.
names: Vec<String>,
/// Every unique character with its advance (em), in order of first appearance.
widths: Vec<(char, f64)>,
/// Where each character is shown: the index of its font in `names`, and its code there.
codes: HashMap<char, (usize, u8)>,
}
/// One glyph of a generated font: its code there, its character, its glyph procedure and advance.
struct Type3Glyph {
code: u8,
ch: char,
path: Vec<u8>,
width: f64,
}
fn pdf_num(v: f64) -> String {
@@ -821,14 +857,20 @@ fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn
faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied())
}
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crate::added::Family, bold: bool) -> Result<Type3Fallback, EditError> {
fn type3_font(
doc: &mut Document,
fonts_res: &mut Dict,
text: &str,
family: crate::added::Family,
bold: bool,
max_glyphs: usize,
) -> Result<Type3Fallback, EditError> {
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold);
// Each unique character once, in order of first appearance.
let mut chars = Vec::new();
let mut seen = std::collections::HashSet::new();
for ch in text.chars() {
if !chars.contains(&ch) {
if chars.len() >= MAX_TYPE3_GLYPHS {
return Err(EditError::Invalid("Japanese replacement has too many unique characters".into()));
}
if seen.insert(ch) {
chars.push(ch);
}
}
@@ -838,27 +880,54 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
// The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every
// character. When none has them all, the best face reports the character it lacks.
let face = fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(no_japanese_font)?;
let family = face.family;
let mut codes = Vec::with_capacity(chars.len());
// Every outline is read before the document is touched, so a character that fails leaves it
// as it was. Each font takes the next `per_font` characters; codes restart at 1 in each.
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() {
let (path, width) = type3_path(face, ch)?;
let code = u8::try_from((i % per_font).saturating_add(1))
.map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
glyphs.push(Type3Glyph { code, ch, path, width });
}
let mut codes = HashMap::with_capacity(glyphs.len());
let mut widths = Vec::with_capacity(glyphs.len());
let mut names = Vec::new();
let mut glyphs = glyphs.into_iter();
loop {
let group: Vec<Type3Glyph> = glyphs.by_ref().take(per_font).collect();
if group.is_empty() {
break;
}
for glyph in &group {
codes.insert(glyph.ch, (names.len(), glyph.code));
widths.push((glyph.ch, glyph.width));
}
names.push(add_type3_font(doc, fonts_res, face, group));
}
Ok(Type3Fallback { family: face.family, names, widths, codes })
}
/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource
/// name (`PCJp`, then `PCJp1`, ...). Nothing here can fail: the outlines were read already.
fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, glyphs: Vec<Type3Glyph>) -> String {
let last_char = glyphs.len();
let mut charprocs = Dict::new();
let mut widths = Vec::with_capacity(chars.len());
let mut widths = Vec::with_capacity(last_char);
let mut differences = vec![Object::Int(1)];
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
cmap.push_str(&format!("{} beginbfchar\n", chars.len()));
for (i, ch) in chars.into_iter().enumerate() {
let code = u8::try_from(i + 1).map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
let glyph_name = format!("g{code:02X}");
let (path, width) = type3_path(face, ch)?;
cmap.push_str(&format!("{last_char} beginbfchar\n"));
for glyph in glyphs {
let glyph_name = format!("g{:02X}", glyph.code);
let mut pd = Dict::new();
pd.set(b"Length".to_vec(), path.len() as i64);
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, path)));
pd.set(b"Length".to_vec(), glyph.path.len() as i64);
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, glyph.path)));
charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref));
differences.push(Object::name(&glyph_name));
widths.push(Object::Real((width * 1000.0).round()));
cmap.push_str(&format!("<{code:02X}> <{}>\n", unicode_hex(ch)));
codes.push((ch, code, width));
widths.push(Object::Real((glyph.width * 1000.0).round()));
cmap.push_str(&format!("<{:02X}> <{}>\n", glyph.code, unicode_hex(glyph.ch)));
}
cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let mut cmap_dict = Dict::new();
@@ -870,7 +939,7 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
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("PCJapanese"));
font.set(b"Name".to_vec(), Object::name(JAPANESE_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(' ', "")));
@@ -885,7 +954,7 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
);
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(codes.len() as i64));
font.set(b"LastChar".to_vec(), Object::Int(last_char as i64));
font.set(b"Widths".to_vec(), Object::Array(widths));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
@@ -897,15 +966,30 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
name = format!("PCJp{suffix}");
}
fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font));
Ok(Type3Fallback { name, family, codes })
name
}
fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<u8>> {
text.chars().map(|ch| fallback.codes.iter().find(|(c, _, _)| *c == ch).map(|(_, code, _)| *code)).collect()
/// The runs that show `text` in the fallback: consecutive characters in one font form one run, so
/// a font is switched only where the text moves to another font. `None` if a character has no code.
fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<(String, Vec<u8>)>> {
let mut runs: Vec<(usize, Vec<u8>)> = Vec::new();
for ch in text.chars() {
let (font, code) = *fallback.codes.get(&ch)?;
match runs.last_mut() {
Some((last, bytes)) if *last == font => bytes.push(code),
_ => runs.push((font, vec![code])),
}
}
runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect()
}
/// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`.
pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result<LineEdit, EditError> {
replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)
}
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result<LineEdit, EditError> {
let text = text.replace(['\n', '\r'], " ");
let lines = text_lines(doc, page)?;
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
@@ -935,12 +1019,14 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
Some(bytes) => replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])),
None => {
if needs_type3(&text) {
let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold)?;
let bytes = type3_encode(&fallback, &text)
let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold, max_glyphs)?;
let runs = type3_encode(&fallback, &text)
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the Japanese fallback")))?;
let size = font_size_before(&ops, first).unwrap_or(target.size);
replacement.push(Op::new("Tf", vec![Object::name(&fallback.name), pdfcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
for (name, bytes) in runs {
replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
}
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
substituted = Some(format!("{} Type3", fallback.family));
} else {
@@ -1043,7 +1129,7 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
let prev = &lines[p];
let g = prev.origin.baseline - l.origin.baseline;
prev.stream == l.stream
&& prev.font == l.font
&& same_font(prev, l.font.as_bytes(), l.face.as_deref())
&& prev.base_font == l.base_font
&& prev.bold == l.bold
&& prev.italic == l.italic
@@ -1144,6 +1230,18 @@ pub struct BlockStyle {
/// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width.
pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result<LineEdit, EditError> {
rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS)
}
/// [`rewrite_block`] with the most unique characters one generated font may hold (tests lower it).
pub(crate) fn rewrite_block_with_limit(
doc: &mut Document,
page: usize,
block: usize,
text: Option<&str>,
style: &BlockStyle,
max_glyphs: usize,
) -> Result<LineEdit, EditError> {
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
@@ -1175,7 +1273,6 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
// The chosen style also determines the real Japanese fallback outlines and advances.
let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic));
let bold = style.bold.unwrap_or(bold);
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold)?) } else { None };
let std_width = move |s: &str, size: f64| -> f64 {
match family {
crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size,
@@ -1196,6 +1293,19 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
None if members.len() == 1 => (b.rect[2] - b.rect[0]).max(size * k).max(p.crop(doc)[2] - 36.0 - (b.rect[0] + dx)),
None => (b.rect[2] - b.rect[0]).max(size * k),
};
// The move and the width are checked here, before the fallback fonts are written: a refused
// paragraph leaves the document as it was. The move is in page space, so it is taken back through
// the CTM's linear part into the space the paragraph is drawn in.
let movement = if dx != 0.0 || dy != 0.0 {
let c = o.ctm;
let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0])
.invert()
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?;
Some(back.apply(dx, dy))
} else {
None
};
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold, max_glyphs)?) } else { None };
let advance = |s: &str| -> f64 {
let t = match (&metrics, reuse) {
(Some(m), true) => m
@@ -1208,7 +1318,13 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
})
.unwrap_or(0.0),
_ if let Some(fallback) = &type3 => {
fallback.codes.iter().map(|(ch, _, width)| s.chars().filter(|c| c == ch).count() as f64 * width * size).sum::<f64>()
// Each character's count in `s`, from one pass over `s`.
let mut counts: HashMap<char, usize> = HashMap::new();
for c in s.chars() {
let count = counts.entry(c).or_insert(0);
*count = count.saturating_add(1);
}
fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * size).sum::<f64>()
+ s.chars().count() as f64 * o_state.char_spacing
}
_ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing,
@@ -1219,12 +1335,12 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
let mut substituted = None;
let new_font = !reuse;
let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) {
(font_name.clone(), Box::new(move |s: &str| m.encode(s)))
let name = font_name.clone();
(font_name.clone(), Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])))
} else if let Some(fallback) = type3.clone() {
let name = fallback.name.clone();
let encoder = fallback.clone();
substituted = Some(format!("{} Type3", fallback.family));
(name, Box::new(move |s: &str| type3_encode(&encoder, s)))
let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?;
(first, Box::new(move |s: &str| type3_encode(&fallback, s)))
} else {
let win = pdfcraft_fonts::win_ansi(&text);
let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect();
@@ -1242,7 +1358,8 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
if style.family.is_none() {
substituted = Some(base.to_string());
}
(name, Box::new(|s: &str| Some(pdfcraft_fonts::win_ansi(s))))
let shown = name.clone();
(name, Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])))
};
// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size.
let lead = match style.line_spacing {
@@ -1254,19 +1371,15 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
let n = pdfcraft_content::num;
// In its own graphics state, so a new colour (or anything else) stops at the paragraph.
let mut block_ops = vec![Op::new("q", vec![])];
// Moved: a translation inside that state. The move is in page space, so it is taken back
// through the CTM's linear part into the space the paragraph is drawn in.
if dx != 0.0 || dy != 0.0 {
let c = o.ctm;
let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0])
.invert()
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?;
let (ax, ay) = back.apply(dx, dy);
// Moved: a translation inside that state.
if let Some((ax, ay)) = movement {
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)]));
}
block_ops.push(Op::new("BT", vec![]));
let mut state = o_state.clone();
state.word_spacing = 0.0;
// The font the next run is shown in: the one the BT sets, until a run of another fallback subset.
let mut shown_font = show_font.clone();
state.font = Some((show_font, size));
if let Some([r, g, bl]) = style.color {
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])];
@@ -1301,8 +1414,13 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
block_ops.push(Op::new("Tw", vec![n(tw)]));
tw_set = tw;
}
let bytes = encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))?;
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
for (font, bytes) in encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
if font != shown_font {
block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)]));
shown_font = font;
}
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
}
let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k;
underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12));
}