edit: a variation selector after existing text draws the base glyph instead of failing the edit (#4899)

This commit is contained in:
midasmatterhorn authored and GitHub committed 2026-11-09 10:09:30 +08:00
1 parent 5e671ec301
commit 98b4761934
3 files changed
+143 -29

No files matched your search

+32
View File
@@ -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]
+76 -16
View File
@@ -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) }]);
}
}
+35 -13
View File
@@ -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(())
}