mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 01:41:18 +00:00
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.
161 lines
7.3 KiB
Rust
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 })
|
|
}
|