mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 22:24:06 +00:00
This commit is contained in:
@@ -3213,6 +3213,175 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() {
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" "), text);
|
||||
}
|
||||
|
||||
/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words
|
||||
/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph),
|
||||
/// one font per word, and the line reads back whole and in place: one line, in its own font, so
|
||||
/// editing it again replaces all of it (review on #2955), also when it starts with a substitute
|
||||
/// word.
|
||||
#[test]
|
||||
fn only_the_words_a_subset_cannot_show_are_substituted() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap();
|
||||
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
|
||||
let mut doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
|
||||
assert_eq!(read, [("ab ba cab ba", "F2")]);
|
||||
// Each word where its font puts it, the substituted `cab` included: no gap or overlap opens
|
||||
// where the font changes (review on #2955).
|
||||
let starts: Vec<f64> = lines[0].ink.iter().map(|b| b[0]).collect();
|
||||
assert_eq!(starts.len(), 4, "{:?}", lines[0].ink);
|
||||
for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) {
|
||||
assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them");
|
||||
}
|
||||
assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink);
|
||||
for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] {
|
||||
text::replace_line(&mut doc, 0, 0, text).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
|
||||
assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine`
|
||||
/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader
|
||||
/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content
|
||||
/// doesn't grow from one edit to the next.
|
||||
#[test]
|
||||
fn replacing_a_mixed_font_line_again_replaces_its_sequence() {
|
||||
let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET");
|
||||
let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count();
|
||||
let mut sizes = Vec::new();
|
||||
for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] {
|
||||
text::replace_line(&mut doc, 0, 0, text).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let mixed = text.contains('c');
|
||||
assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}");
|
||||
assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}");
|
||||
assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect();
|
||||
assert_eq!(read, [text, "ba"], "after {text:?}");
|
||||
assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place");
|
||||
sizes.push(content.split_whitespace().count());
|
||||
}
|
||||
assert_eq!(sizes[2], sizes[3], "the same text again, the same content");
|
||||
// A sequence holding marked content of its own goes whole, and what follows it keeps its
|
||||
// font and size unless the replacement sets the same again.
|
||||
for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] {
|
||||
let mut doc = text_page(&format!(
|
||||
"BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET"
|
||||
));
|
||||
text::replace_line(&mut doc, 0, 0, "ab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect();
|
||||
assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the
|
||||
/// subset can show) was split into an empty source-font run and a substitute run, so it was
|
||||
/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line
|
||||
/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is
|
||||
/// substituted as a whole instead, as a line with no word in the source font always was.
|
||||
#[test]
|
||||
fn a_leading_space_does_not_make_a_source_font_run() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
|
||||
// With a word in the source font after it, the line is still written in both.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " ab cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab cab", "F2")]);
|
||||
// The spaces the subset has no glyph for are moves, each one kept: the leading one before
|
||||
// the first word, two between words as two.
|
||||
let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc));
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc));
|
||||
// Before a substitute word, the leading spaces are the substitute's to show, all of them.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("( cab) Tj"), "{content}");
|
||||
}
|
||||
|
||||
/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier
|
||||
/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A
|
||||
/// later replacement of one of those rows must not take the sequence's other rows with it.
|
||||
#[test]
|
||||
fn replacing_one_row_of_a_sequence_keeps_the_other_rows() {
|
||||
let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET";
|
||||
let rows = text::text_lines(&text_page(content), 0).unwrap();
|
||||
assert_eq!(rows.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab", "ba"]);
|
||||
for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] {
|
||||
let mut doc = text_page(content);
|
||||
text::replace_line(&mut doc, 0, row, text).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let after: Vec<String> = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect();
|
||||
assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run
|
||||
/// has the substitute's font in effect at its first operator. Replaced by text the row's own font
|
||||
/// shows, it must set that font again, or the new text is drawn in the substitute.
|
||||
#[test]
|
||||
fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() {
|
||||
// A real two-font line, starting with the substitute run, then a second row put inside its
|
||||
// sequence, as a paragraph rewrite that keeps the delimiters can leave it.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}");
|
||||
let mut doc = doc;
|
||||
let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1);
|
||||
let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes()))));
|
||||
set_page_contents(&mut doc, stream, None);
|
||||
let rows = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("cab ab", "F2"), ("ba", "F2")]);
|
||||
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let after = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab", "F2"), ("ba", "F2")]);
|
||||
}
|
||||
|
||||
/// Review on #2955: a sequence holding one row can also hold that row's positioning and text
|
||||
/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the
|
||||
/// delimiters, fonts and text, not the `Tm` that places it.
|
||||
#[test]
|
||||
fn replacing_the_only_row_of_a_sequence_keeps_its_position() {
|
||||
let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET";
|
||||
let mut doc = text_page(content);
|
||||
let before = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(before.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab"]);
|
||||
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}");
|
||||
let after = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(after.len(), 1, "{content}");
|
||||
let (was, now) = (before[0].rect, after[0].rect);
|
||||
assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! dropped, and every positioning operator stays, so the lines after it keep their places
|
||||
//! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change).
|
||||
//!
|
||||
//! The new text uses the line's own font when every character has a code and a glyph in it;
|
||||
//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted.
|
||||
//! The new text uses the line's own font when every character has a code and a glyph in it.
|
||||
//! Otherwise the words the font can show keep it and only the others are set in Helvetica
|
||||
//! (WinAnsi), run by run, and the result says the font was substituted.
|
||||
//! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -132,6 +133,9 @@ pub struct TextLine {
|
||||
/// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none
|
||||
/// (#2529). Meaningless otherwise.
|
||||
soft_gap: usize,
|
||||
/// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in
|
||||
/// (see [`fallback::continues_font`]).
|
||||
edited: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::interp::Ts;
|
||||
use super::interp::{Shown, Ts};
|
||||
use super::lines::page_dict;
|
||||
use super::{
|
||||
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
|
||||
@@ -148,6 +148,23 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option<std::col
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Whether run `s` may continue `line` font-wise: the same font, or both inside the `/PCEdLine`
|
||||
/// sequence of a replacement that set the words its font can't show in a substitute (#2378),
|
||||
/// which reads back as the one line it replaced, so the next edit replaces all of it.
|
||||
pub(super) fn continues_font(line: &TextLine, s: &Shown) -> bool {
|
||||
same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited)
|
||||
}
|
||||
|
||||
/// A line that began with a substitute run takes the source font of a later run, so the next
|
||||
/// replacement starts from the line's own font again.
|
||||
pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) {
|
||||
if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") {
|
||||
line.font = String::from_utf8_lossy(&s.font).into_owned();
|
||||
line.base_font = s.base_font.clone();
|
||||
line.face = s.face.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese
|
||||
/// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another
|
||||
/// resource for the same font (a writer may split one font into several subsets, #3661), or
|
||||
@@ -730,6 +747,30 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in
|
||||
/// order. A word is shown in one font, so a word with any character the font lacks goes to the
|
||||
/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are
|
||||
/// in, since [`source_show`] draws spaces the font lacks with a displacement.
|
||||
pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> {
|
||||
let mut runs: Vec<(bool, String)> = Vec::new();
|
||||
// Spaces before the first word go with it: on their own they would make a run of the line's
|
||||
// font with nothing to show (review on #2955).
|
||||
let mut lead = String::new();
|
||||
for (i, word) in text.split(' ').enumerate() {
|
||||
let piece = if i == 0 { word.to_string() } else { format!(" {word}") };
|
||||
if runs.is_empty() && word.is_empty() {
|
||||
lead.push_str(&piece);
|
||||
continue;
|
||||
}
|
||||
let own = word.is_empty() || metrics.encode(word).is_some();
|
||||
match runs.last_mut() {
|
||||
Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece),
|
||||
_ => runs.push((own, std::mem::take(&mut lead) + &piece)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -359,6 +359,7 @@ pub(super) struct Shown {
|
||||
/// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer),
|
||||
/// matching the single space a rejoin always used before #2529.
|
||||
pub(super) soft_gap: usize,
|
||||
pub(super) edited: Option<usize>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
@@ -537,6 +538,8 @@ type Invocation = (usize, Vec<u8>, Ts);
|
||||
struct MarkedContent {
|
||||
regions: Vec<Option<usize>>,
|
||||
layers: Vec<Option<Vec<u8>>>,
|
||||
// The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last.
|
||||
edited: Vec<Option<usize>>,
|
||||
actual_text: Vec<Option<(usize, usize, String)>>,
|
||||
}
|
||||
|
||||
@@ -550,6 +553,7 @@ impl MarkedContent {
|
||||
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
|
||||
self.layers
|
||||
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
|
||||
self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() });
|
||||
// The property list is inline or named in the resources' /Properties (§14.6.2).
|
||||
let list = logical_text.and_then(|properties| match op.operands.get(1) {
|
||||
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
|
||||
@@ -565,6 +569,7 @@ impl MarkedContent {
|
||||
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
|
||||
self.regions.pop();
|
||||
self.layers.pop();
|
||||
self.edited.pop();
|
||||
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
|
||||
let tail = shown.split_off(start.min(shown.len()));
|
||||
if tail.iter().any(|s| s.glyph_count > 0) {
|
||||
@@ -814,6 +819,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
|
||||
soft_break: state.soft_break,
|
||||
hard_break: state.hard_break,
|
||||
soft_gap: state.soft_gap,
|
||||
edited: marked.edited.last().copied().flatten(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::fallback::{same_font, same_paint};
|
||||
use super::fallback::{adopt_source_font, continues_font, same_paint};
|
||||
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
|
||||
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
|
||||
|
||||
@@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
piece == si
|
||||
&& lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01
|
||||
@@ -374,6 +374,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
if l.unreadable_mode.is_none() {
|
||||
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
|
||||
}
|
||||
adopt_source_font(l, &s);
|
||||
// Spaces may use a separate subset font; they do not change the visible style.
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
@@ -401,6 +402,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
edited: s.edited,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -461,7 +463,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
let bt = pen.bt;
|
||||
lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01
|
||||
@@ -492,6 +494,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
if l.unreadable_mode.is_none() {
|
||||
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
|
||||
}
|
||||
adopt_source_font(l, &s);
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
@@ -518,6 +521,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
edited: s.edited,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
|
||||
use super::fallback::{
|
||||
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font,
|
||||
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
};
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
@@ -110,10 +110,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(format!("{} Type3", fallback.family));
|
||||
} else {
|
||||
// The words the line's font can show keep it; only the others are set in the
|
||||
// substitute (#2378): a subset without `V` or `ü` no longer turns the whole line
|
||||
// into Helvetica. Reordered text (right to left, Indic) keeps one run as before.
|
||||
let mixed = font
|
||||
.as_ref()
|
||||
.filter(|_| !visual_indic && fallback_shown == text)
|
||||
.map(|m| source_runs(m, &text))
|
||||
.filter(|runs| runs.iter().any(|(own, _)| *own));
|
||||
// What the substitute has to show: only the runs the line's font can't.
|
||||
let for_substitute: String = match &mixed {
|
||||
Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(),
|
||||
None => fallback_shown.clone(),
|
||||
};
|
||||
let win = pdfcraft_fonts::win_ansi(&fallback_shown);
|
||||
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
|
||||
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
let checked = pdfcraft_fonts::win_ansi(&for_substitute);
|
||||
let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
|
||||
}
|
||||
// The size in text space: the current Tf's size.
|
||||
@@ -121,8 +135,36 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
let family = source_family(&target.base_font);
|
||||
let base = family.base_font(target.bold, target.italic);
|
||||
let substitute_name = format!("PCEd{}", base.replace('-', ""));
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
match mixed {
|
||||
Some(runs) => {
|
||||
let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m));
|
||||
// Marked as one line, so it reads back as one (see `continues_font`).
|
||||
replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")]));
|
||||
for (own, run) in runs {
|
||||
if own {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
if let Some(m) = font.as_ref() {
|
||||
replacement.extend(source_show(
|
||||
m,
|
||||
&run,
|
||||
target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size),
|
||||
space,
|
||||
target.origin.state.char_spacing,
|
||||
target.origin.state.word_spacing,
|
||||
)?);
|
||||
}
|
||||
} else {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
None => {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(base.to_string());
|
||||
let mut f = Dict::new();
|
||||
@@ -135,6 +177,43 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
}
|
||||
}
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go.
|
||||
// A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own,
|
||||
// with its fonts and the moves standing in for spaces the source font lacks: those go
|
||||
// with the line, or each edit would nest one more (review on #2955). The line then starts in
|
||||
// its own font, set again where the sequence was.
|
||||
if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) {
|
||||
// Only when the sequence holds nothing but this line: a paragraph rewrite keeps the
|
||||
// delimiters, so other rows can be inside it (review on #2955). Then the sequence stays
|
||||
// as it is, since it is also what reads its own two-font line back as one, and only
|
||||
// this line's operators go.
|
||||
let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o)));
|
||||
// Either way the replacement starts in the line's own font: inside the sequence the font
|
||||
// in effect at the line's first operator can be the substitute's, which a replacement
|
||||
// the line's font shows by itself would otherwise inherit (review on #2955).
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
if !others_inside {
|
||||
// What goes is what the replacement stands in for: the marked-content delimiters
|
||||
// (nested ones too, which come in pairs), the fonts and the text-showing operators
|
||||
// (the line's runs and the moves between them). A paragraph rewrite can also write a
|
||||
// row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET`
|
||||
// inside kept delimiters; those stay, or the replacement loses its place and the
|
||||
// content its balance (review on #2955).
|
||||
drops.extend(
|
||||
(open..=close)
|
||||
.filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))),
|
||||
);
|
||||
let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size));
|
||||
// The same font set just before the sequence (by the last edit) is set again here.
|
||||
if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) {
|
||||
drops.insert(i);
|
||||
}
|
||||
// And set back after it: the replacement ends in that font too.
|
||||
if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) {
|
||||
drops.insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
|
||||
let contents = rewritten_contents(doc, &streams, new_data)?;
|
||||
let page_ref = p.obj;
|
||||
@@ -150,6 +229,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
Ok(LineEdit { substituted })
|
||||
}
|
||||
|
||||
/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins.
|
||||
fn matching_emc(ops: &[Op], open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (i, op) in ops.iter().enumerate().skip(open) {
|
||||
match op.op.as_slice() {
|
||||
b"BMC" | b"BDC" => depth = depth.saturating_add(1),
|
||||
b"EMC" => {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The size of the last `Tf` before operator `at`.
|
||||
fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
|
||||
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user