mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
edit: a variation selector after existing text draws the base glyph instead of failing the edit (#4899)
This commit is contained in:
1 parent
5e671ec301
commit
98b4761934
3 files changed
+143
-29
No files matched your search
@@ -11279,6 +11279,38 @@ fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() {
|
||||
let replacement = "1\u{FE0F}";
|
||||
let mut results = Vec::new();
|
||||
for kind in ["line", "block"] {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
if kind == "line" {
|
||||
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
|
||||
} else {
|
||||
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
|
||||
}
|
||||
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
results.push((kind, lines[0].text.clone()));
|
||||
}
|
||||
assert_eq!(results, vec![("line", replacement.to_owned()), ("block", replacement.to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() {
|
||||
// қ requires the same bundled fallback for both drafts, without optional font inputs.
|
||||
for spacing in [0.0, 2.0] {
|
||||
let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let mut selected = plain.clone();
|
||||
let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() };
|
||||
text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap();
|
||||
text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap();
|
||||
let a = text::text_lines(&reopen(&plain), 0).unwrap();
|
||||
let b = text::text_lines(&reopen(&selected), 0).unwrap();
|
||||
assert_eq!(a[0].base_font, b[0].base_font);
|
||||
assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect);
|
||||
}
|
||||
}
|
||||
/// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen.
|
||||
/// Selected-row alignment for existing PDF content is withheld until it can persist safely.
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,27 @@ use super::{
|
||||
};
|
||||
|
||||
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
|
||||
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
|
||||
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<Type3Run>>>;
|
||||
|
||||
/// One run a rewrite shows: bytes drawn in one generated font. `spacing` set (a variation
|
||||
/// selector's run) shows at that character spacing: its empty glyph advances nothing, and the
|
||||
/// spacing in force must not add to it either.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Type3Run {
|
||||
pub(super) name: String,
|
||||
pub(super) bytes: Vec<u8>,
|
||||
pub(super) spacing: Option<f64>,
|
||||
}
|
||||
|
||||
/// A Unicode variation selector (U+FE00..U+FE0F): it picks a variant of the character before it
|
||||
/// and has no glyph of its own. The Type 3 fallback gives it an empty zero-width glyph instead of
|
||||
/// failing the edit on it.
|
||||
pub(super) fn is_variation_selector(ch: char) -> bool {
|
||||
matches!(u32::from(ch), 0xFE00..=0xFE0F)
|
||||
}
|
||||
|
||||
/// A variation selector's glyph procedure: nothing painted, zero advance.
|
||||
const SELECTOR_GLYPH_PROC: &[u8] = b"0 0 0 0 0 0 d1\n";
|
||||
|
||||
fn is_win_ansi_char(c: char) -> bool {
|
||||
c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some()
|
||||
@@ -377,7 +397,13 @@ pub(super) fn type3_font(
|
||||
// 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. Hebrew
|
||||
// text keeps the Hebrew face and takes each character it lacks from the next face that has it.
|
||||
let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) }
|
||||
// A variation selector has no outline anywhere: it takes a glyph slot of its own below, so the
|
||||
// face is chosen by the characters that have outlines.
|
||||
let named: Vec<char> = chars.iter().copied().filter(|ch| !is_variation_selector(*ch)).collect();
|
||||
if named.is_empty() {
|
||||
return Err(EditError::Invalid("replacement text is empty".into()));
|
||||
}
|
||||
let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &named, |face, ch| japanese_glyph_from(face, ch).is_ok()) }
|
||||
.ok_or_else(|| no_fallback_font(text))?;
|
||||
let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] };
|
||||
// Every outline is read before the document is touched, so a character that fails leaves it
|
||||
@@ -385,15 +411,21 @@ pub(super) 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() {
|
||||
// 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;
|
||||
let drawn = if is_variation_selector(ch) {
|
||||
Some((SELECTOR_GLYPH_PROC.to_vec(), 0.0))
|
||||
} else {
|
||||
// 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)?;
|
||||
}
|
||||
drawn = type3_glyph(f, ch)?;
|
||||
}
|
||||
drawn
|
||||
};
|
||||
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()))?;
|
||||
@@ -580,17 +612,22 @@ pub(crate) fn type3_font_dict(doc: &mut Document, glyphs: &[(char, Vec<u8>, f64)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<(String, Vec<u8>)>> {
|
||||
let mut runs: Vec<(usize, Vec<u8>)> = Vec::new();
|
||||
/// a font is switched only where the text moves to another font. A variation selector forms its
|
||||
/// own run, drawn at character spacing zero (see [`Type3Run`]). `None` if a character has no code.
|
||||
pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<Type3Run>> {
|
||||
let mut runs: Vec<(usize, Vec<u8>, Option<f64>)> = Vec::new();
|
||||
for ch in text.chars() {
|
||||
let (font, code) = *fallback.codes.get(&ch)?;
|
||||
if is_variation_selector(ch) {
|
||||
runs.push((font, vec![code], Some(0.0)));
|
||||
continue;
|
||||
}
|
||||
match runs.last_mut() {
|
||||
Some((last, bytes)) if *last == font => bytes.push(code),
|
||||
_ => runs.push((font, vec![code])),
|
||||
Some((last, bytes, None)) if *last == font => bytes.push(code),
|
||||
_ => runs.push((font, vec![code], None)),
|
||||
}
|
||||
}
|
||||
runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect()
|
||||
runs.into_iter().map(|(font, bytes, spacing)| Some(Type3Run { name: fallback.names.get(font)?.clone(), bytes, spacing })).collect()
|
||||
}
|
||||
|
||||
/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose
|
||||
@@ -685,3 +722,26 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variation_selectors_show_an_empty_glyph_at_zero_spacing() {
|
||||
assert!(is_variation_selector('\u{FE0F}'));
|
||||
assert!(!is_variation_selector('\u{27A1}'));
|
||||
let fallback = Type3Fallback {
|
||||
family: "Inter",
|
||||
names: vec!["PCLat".into()],
|
||||
widths: vec![('1', 0.556), ('\u{FE0F}', 0.0)],
|
||||
codes: [('1', (0, 1)), ('\u{FE0F}', (0, 2))].into_iter().collect(),
|
||||
};
|
||||
let runs = type3_encode(&fallback, "1\u{FE0F}").expect("the selector after a drawn base is kept, not an error");
|
||||
assert_eq!(runs.len(), 2, "the selector forms its own run");
|
||||
assert_eq!(runs[0], Type3Run { name: "PCLat".into(), bytes: vec![1], spacing: None });
|
||||
assert_eq!(runs[1], Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }, "the selector draws at spacing zero");
|
||||
let runs = type3_encode(&fallback, "\u{FE0F}").expect("a lone selector keeps its empty glyph rather than failing");
|
||||
assert_eq!(runs, vec![Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
|
||||
use super::fallback::{
|
||||
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family,
|
||||
source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
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,
|
||||
};
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
@@ -94,9 +94,18 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
let runs = type3_encode(&fallback, &fallback_shown)
|
||||
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?;
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
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))]));
|
||||
let spacing = target.origin.state.char_spacing;
|
||||
for run in runs {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&run.name), pdfcraft_content::num(size)]));
|
||||
if let Some(tc) = run.spacing {
|
||||
// A variation selector's empty glyph: shown at spacing zero, then the
|
||||
// line's own spacing back, so it adds no advance.
|
||||
replacement.push(Op::new("Tc", vec![pdfcraft_content::num(tc)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
replacement.push(Op::new("Tc", vec![pdfcraft_content::num(spacing)]));
|
||||
} else {
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(format!("{} Type3", fallback.family));
|
||||
@@ -351,7 +360,8 @@ struct Advance<'a> {
|
||||
|
||||
impl Advance<'_> {
|
||||
fn of(&self, s: &str) -> f64 {
|
||||
let extra = s.chars().count() as f64 * self.char_spacing;
|
||||
// A variation selector draws nothing and adds no spacing: it takes no room.
|
||||
let extra = s.chars().filter(|ch| !is_variation_selector(*ch)).count() as f64 * self.char_spacing;
|
||||
let t = match (self.source, self.type3) {
|
||||
(Some(m), _) => {
|
||||
let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() };
|
||||
@@ -399,7 +409,7 @@ fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result<FontChoi
|
||||
let name = inputs.font_name.to_owned();
|
||||
return Ok(FontChoice {
|
||||
show_font: name.clone(),
|
||||
encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])),
|
||||
encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![Type3Run { name: name.clone(), bytes, spacing: None }])),
|
||||
substituted: None,
|
||||
});
|
||||
}
|
||||
@@ -426,7 +436,11 @@ fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result<FontChoi
|
||||
fonts_res.set(name.clone().into_bytes(), Object::Dict(f));
|
||||
let substituted = (!inputs.family_requested).then(|| base.to_string());
|
||||
let shown = name.clone();
|
||||
Ok(FontChoice { show_font: name, encode: Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])), substituted })
|
||||
Ok(FontChoice {
|
||||
show_font: name,
|
||||
encode: Box::new(move |s: &str| Some(vec![Type3Run { name: shown.clone(), bytes: pdfcraft_fonts::win_ansi(s), spacing: None }])),
|
||||
substituted,
|
||||
})
|
||||
}
|
||||
|
||||
/// The paragraph's move in the space it is drawn in: the move is in page space, so it is taken
|
||||
@@ -656,12 +670,20 @@ impl LineWriter<'_> {
|
||||
ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?);
|
||||
return Ok(());
|
||||
}
|
||||
for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if font != cursor.shown_font {
|
||||
ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)]));
|
||||
cursor.shown_font = font;
|
||||
for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if run.name != cursor.shown_font {
|
||||
ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)]));
|
||||
cursor.shown_font = run.name;
|
||||
}
|
||||
if let Some(tc) = run.spacing {
|
||||
// A variation selector's empty glyph: shown at spacing zero, then the paragraph's
|
||||
// own spacing back, so it adds no advance.
|
||||
ops.push(Op::new("Tc", vec![n(tc)]));
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
ops.push(Op::new("Tc", vec![n(self.char_spacing)]));
|
||||
} else {
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
}
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user