Files
pdfcraft/crates/edit/src/text/editor.rs
T
xullul c7e8d127af M7: edit grouped Thai text while preserving source formatting
Add:
- Prepare supported Form placements as original page operators through the
  existing-text UI and text_prepare, with shared-form isolation and undo/redo.
- Repair narrowly identified Thai exporter maps on copied page fonts, recording
  repairs without changing the embedded source glyph programs.

Change:
- Reuse legacy Thai Regular/Bold glyph codes and same-face punctuation subsets;
  retain point size, spacing, horizontal scale, rise, colour and paint settings.
- Keep cross-family Thai replacement explicit and write matching bundled Thai
  subsets through the existing bounded outline path rather than static embedding.
- Keep replacement-only menu choices out of the source-font preview atlas so
  opening an ordinary paragraph does not wait an extra frame for unused fonts.
- Refuse unsafe compositing, tagged or malformed groups, and missing contextual
  glyphs without losing the user's document or changing other placements.

Chores:
- Document the editing limits and headless workflow; keep text-edit parity partial.

Builds on dekkeng's Thai fallback work in #2811 (fba0a29a) and mtlcl410's Form
Ungroup implementation in #3067 (6eafca9c), adapted to the current module layout.
The Ungroup menu and older UI font-priority changes are not included.

Refs #3185, #2964 and #2142. Synthetic regressions cover source-font preservation,
shared forms, unsafe refusals, layout, undo/redo, rendering and save/reopen.

Validation: library suites 1,675 passed; automation integration 331 passed;
document-font integration 2 passed; editor flows 248 passed. Existing ignored
tests remain. Formatting, scoped strict Clippy, layers, assets, parity and wasm
checks passed. The unchanged opacity regression failed before the choice-only
atlas correction and passed afterwards.
2026-11-07 12:27:29 +07:00

161 lines
7.3 KiB
Rust

use super::blocks::{BlockStyle, TextBlock};
use super::fallback::MAX_TYPE3_GLYPHS;
use super::lines::{page_dict, text_lines};
use super::rewrite::rewrite_block_from;
use super::{DecodeBudget, Dict, Document, EditError, Metrics, Object, TextLine, TextSession};
/// An embedded source font or an approved bundled document-font choice.
/// Source bytes stay in memory and are never installed or copied into another document.
#[derive(Clone, Debug)]
pub struct TextEditorFont {
pub resource: String,
pub face: String,
pub bytes: Vec<u8>,
/// A selectable replacement, rather than a source/matching preview face. Merely listing
/// this choice must not reload an open editor's font atlas.
pub choice_only: bool,
}
/// Reserved document-font choice for explicitly replacing text with the bundled Thai face.
/// This is a selector, never a font resource written into a PDF.
const BUILTIN_THAI_FONT: &str = "builtin:thai";
pub(super) fn builtin_thai_choice(fonts: &Dict) -> String {
let mut choice = BUILTIN_THAI_FONT.to_owned();
while fonts.contains(choice.as_bytes()) {
choice.push('_');
}
choice
}
pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFont>, EditError> {
let p = page_dict(doc, page)?;
let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default();
let mut budget = 32_usize << 20;
// Every program decoded counts, usable or not, so aliases of one program can't repeat the work.
let mut decode = DecodeBudget::new(2 * budget, 0);
let mut result = Vec::new();
for (name, font) in fonts.iter().take(128) {
let Some(font) = doc.dict(font) else { continue };
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt_within(doc, &font, &mut decode) else { continue };
if bytes.len() > budget {
continue;
}
budget -= bytes.len();
result.push(TextEditorFont {
resource: String::from_utf8_lossy(name).into_owned(),
face: Metrics::from_dict(doc, &font).base_font.rsplit('+').next().unwrap_or_default().to_owned(),
bytes,
choice_only: false,
});
}
// The approved full face follows all source subsets in the editor's font stack. It can
// display newly inserted glyphs before Apply without changing the source text's face.
let source_faces: Vec<_> = result.iter().map(|p| p.face.clone()).collect();
for face in source_faces {
for full in pdfcraft_fonts::embedding::matching_faces(&face) {
if full.bytes.len() > budget || result.iter().any(|p| p.resource == format!("PCApproved{}", full.name())) {
continue;
}
budget -= full.bytes.len();
result.push(TextEditorFont {
resource: format!("PCApproved{}", full.name()),
face: face.clone(),
bytes: full.bytes.to_vec(),
choice_only: false,
});
}
}
// A missing source family must not make the bundled Thai replacement undiscoverable.
// Offering it does not change the source; callers explicitly select this choice to replace it.
if let Some(thai) = pdfcraft_fonts::document_thai_font()
&& thai.bytes.len() <= budget
{
result.push(TextEditorFont {
resource: builtin_thai_choice(&fonts),
face: thai.family.into(),
bytes: thai.bytes.to_vec(),
choice_only: true,
});
}
Ok(result)
}
/// Load an explicit full face into this page's resources. Nothing on the page is replaced;
/// the new resource can complete matching source subsets or be chosen in a text style.
pub fn register_text_font(doc: &mut Document, page: usize, bytes: &[u8]) -> Result<String, EditError> {
pdfcraft_fonts::embedding::validate_document_input(bytes).map_err(EditError::Invalid)?;
let (_, postscript) = pdfcraft_fonts::embedding::names(bytes).map_err(EditError::Invalid)?;
let p = page_dict(doc, page)?;
let mut resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
let mut fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default();
let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, bytes, &postscript).map_err(EditError::Invalid)?;
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources)))?;
Ok(name)
}
/// Read-only draft bounds, measured with the same PDF rewrite used by Apply/Save.
#[derive(Clone, Debug, PartialEq)]
pub struct TextEditLayout {
pub source_rect: [f64; 4],
pub draft_rect: Option<[f64; 4]>,
pub overflow: bool,
}
pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result<TextEditLayout, EditError> {
TextSession::open(doc, page, block)?.layout(text, style)
}
/// A draft of paragraph `source` (number `block` among the page's `lines`) laid out by the rewrite
/// Apply runs, on a copy of `doc`.
pub(crate) fn layout_draft(
doc: &Document,
page: usize,
lines: &[TextLine],
source: &TextBlock,
block: usize,
text: &str,
style: &BlockStyle,
) -> Result<TextEditLayout, EditError> {
if text == source.editor_text && *style == BlockStyle::default() {
return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false });
}
// Subtract the untouched runs by value, rather than by operator offsets: a rewrite may
// split BT/ET or add font resources. A multiset also handles coincident repeated text.
let key =
|line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits));
let mut untouched = std::collections::BTreeMap::new();
for (index, line) in lines.iter().enumerate() {
if !source.lines.contains(&index) {
*untouched.entry(key(line)).or_insert(0_usize) += 1;
}
}
let mut draft = doc.clone();
rewrite_block_from(&mut draft, page, lines.to_vec(), block, Some(text), style, MAX_TYPE3_GLYPHS)?;
let mut rect: Option<[f64; 4]> = None;
for line in text_lines(&draft, page)? {
if let Some(count) = untouched.get_mut(&key(&line))
&& *count > 0
{
*count -= 1;
continue;
}
// Spaces typed after a row's last word are drawn but show nothing, so an upright row is
// measured to the end of its last word: they don't make a draft overflow (#4199).
let mut r = line.rect;
if line.upright() && !line.ink.is_empty() {
if crate::bidi::rtl_base(&line.text) {
r[0] = r[0].max(line.ink.iter().map(|i| i[0]).fold(f64::INFINITY, f64::min));
} else {
r[2] = r[2].min(line.ink.iter().map(|i| i[2]).fold(f64::NEG_INFINITY, f64::max));
}
}
rect = Some(rect.map_or(r, |a| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]));
}
let overflow = rect
.is_some_and(|r| r[0] < source.rect[0] - 0.5 || r[1] < source.rect[1] - 0.5 || r[2] > source.rect[2] + 0.5 || r[3] > source.rect[3] + 0.5);
Ok(TextEditLayout { source_rect: source.rect, draft_rect: rect, overflow })
}