From 98b4761934caf08ff2ceb95a8fb77865f0b6c174 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:09:30 +0300 Subject: [PATCH] edit: a variation selector after existing text draws the base glyph instead of failing the edit (#4899) --- crates/edit/src/tests.rs | 32 +++++++++++ crates/edit/src/text/fallback.rs | 92 ++++++++++++++++++++++++++------ crates/edit/src/text/rewrite.rs | 48 ++++++++++++----- 3 files changed, 143 insertions(+), 29 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ba80058c45..87eb16a36f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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] diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 80b20e0cda..789ffc5456 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -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 Option)>>>; +pub(super) type Encoder = Box Option>>; + +/// 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, + pub(super) spacing: Option, +} + +/// 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 = 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, 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)>> { - let mut runs: Vec<(usize, Vec)> = 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> { + let mut runs: Vec<(usize, Vec, Option)> = 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) }]); + } +} diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c19f4ce98f..692c5e68de 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -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, fonts_res: &mut Dict) -> Result { 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(()) }