mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 10:46:38 +00:00
Merge pull request #6627 from storytold/fix/thai-grouped-text
M7: edit grouped Thai text with one click, on Ungroup's form expansion (#6249 + review fixes)
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
|
||||
@@ -237,6 +239,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
|
||||
|
||||
- **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged.
|
||||
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
|
||||
- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged.
|
||||
|
||||
|
||||
@@ -209,6 +209,30 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol
|
||||
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
|
||||
and [image editing](../../docs/editing-images.md).
|
||||
|
||||
`text_fonts` also lists `builtin:thai` when the build includes the bundled Noto Sans Thai face
|
||||
(the optional craft-fonts input). Passing it as `text_edit.font_resource` explicitly replaces a
|
||||
paragraph or range with that face; the source font is never swapped for it silently. A build
|
||||
without the face never lists it, and `text_edit` refuses it there. A page whose own fonts fill
|
||||
the listing's 32 MiB preview budget can leave it out too; `text_edit` still accepts it then.
|
||||
|
||||
### Preparing grouped text
|
||||
|
||||
`text_prepare {doc, page}` makes supported grouped Form text editable on one page, as one click
|
||||
on grouped text in the GUI's Edit text does. It keeps the group's original glyph operators and shared source
|
||||
objects, records preparation and map repairs in the repair log, and is one undoable step. List
|
||||
`text_paragraphs` afterwards; its indices belong to the prepared page. Besides the document
|
||||
summary it returns what this edit did:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `prepared_groups` | integer | Form placements drawn in place, so their text is now the page's own. |
|
||||
| `repaired_fonts` | integer | Page fonts whose damaged legacy THSarabunPSK Thai map was repaired on a page-owned copy. |
|
||||
| `refused_groups` | array of strings | One `name: reason` per group left as it was: unsafe compositing, tags, malformed content, or text grouped more than eight levels deep. |
|
||||
|
||||
The page's own fonts with that damaged map are repaired too, whether their text was grouped or
|
||||
not. It fails, changing nothing, when the page has nothing to prepare or repair (the error gives
|
||||
the first reason a group was left as it was) or the page is out of range.
|
||||
|
||||
### Superscript and subscript normalization
|
||||
|
||||
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
|
||||
|
||||
@@ -481,6 +481,10 @@ impl Automation {
|
||||
self.apply(&a, Edit::EditPageImage { page, index, change })?
|
||||
}
|
||||
}
|
||||
"text_prepare" => {
|
||||
let page = self.page(&a)?;
|
||||
self.prepare_text(&a, page)?
|
||||
}
|
||||
"text_paragraphs" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
|
||||
@@ -502,6 +502,23 @@ impl Automation {
|
||||
pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.applied(a)
|
||||
}
|
||||
|
||||
/// `text_prepare`: make a page's grouped text editable, with what that edit itself prepared,
|
||||
/// repaired and left grouped.
|
||||
pub(crate) fn prepare_text(&mut self, a: &Args, page: usize) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let prepared = self.session.prepare_text(id, page).map_err(failed)?;
|
||||
let mut out = self.applied(a)?;
|
||||
out["prepared_groups"] = json!(prepared.expanded);
|
||||
out["repaired_fonts"] = json!(prepared.repaired_fonts);
|
||||
out["refused_groups"] = json!(prepared.refused);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The document's summary after an edit, with what it stored as typed but drew with `?`.
|
||||
fn applied(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let mut out = summary(doc);
|
||||
// Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent.
|
||||
|
||||
@@ -1588,9 +1588,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])),
|
||||
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
|
||||
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources and bundled choices can be selected by text_edit.font_resource. builtin:thai explicitly replaces a paragraph or range with the bundled Noto Sans Thai; a build without that face never lists it and text_edit refuses it there (a page whose fonts fill the listing budget may also omit it, but text_edit still accepts it). preview_only faces cannot be selected.")
|
||||
.ro().cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("text_prepare", "Prepare grouped text", "Make existing text inside supported Form XObjects directly editable on one page. Replaces placements with their original operators and resources, preserving position, clipping and other pages. Refuses unsafe transparency, tags or malformed content. Adds no text or cover boxes. Returns prepared_groups (placements made editable), repaired_fonts (damaged legacy Thai maps repaired) and refused_groups ('name: reason' for each group left as it was). Also repairs the page's own damaged legacy Thai maps, grouped or not. Fails when the page has nothing to prepare or repair. Undoable; list text_paragraphs again afterwards.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.")
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
@@ -1604,7 +1607,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
|
||||
"paragraph": { "type": "integer", "minimum": 1 },
|
||||
"font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." },
|
||||
"font_resource": { "type": "string", "description": "Paragraphs: source/full page resource or bundled document-font choice from text_fonts (builtin:thai selects the bundled Thai face); also accepts text_font_import resources." },
|
||||
"ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." },
|
||||
"text": { "type": "string" },
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] },
|
||||
|
||||
@@ -11862,3 +11862,151 @@ fn comments_copy_and_paste_between_documents_through_tools() {
|
||||
assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::write(dir.join("source.pdf"), fixture(1)).unwrap();
|
||||
let mut a = auto(dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = page_text(&mut a, doc);
|
||||
let fonts = ok(&mut a, "text_fonts", json!({"doc":doc,"page":1}));
|
||||
let choice = fonts["fonts"].as_array().unwrap().iter().find(|f| f["resource"] == "builtin:thai");
|
||||
let args = json!({"doc":doc,"page":1,"paragraph":1,"text":"น้ำ กิ่ กี้ ผู้ใช้","font_resource":"builtin:thai"});
|
||||
if pdfcraft_fonts::document_thai_font().is_none() {
|
||||
assert!(choice.is_none());
|
||||
assert!(a.call("text_edit", &args).is_err());
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
return;
|
||||
}
|
||||
assert_eq!(choice.unwrap()["preview_only"], false);
|
||||
ok(&mut a, "text_edit_layout", args.clone());
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
ok(&mut a, "text_edit", args);
|
||||
let edited = page_text(&mut a, doc);
|
||||
assert!(edited[0].contains("น้ำ กิ่ กี้ ผู้ใช้"));
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page_text(&mut a, reopened), edited);
|
||||
let Err(ToolError::Failed(m)) =
|
||||
a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"น้ำ 😀","font_resource":"builtin:thai"}))
|
||||
else {
|
||||
panic!("Noto Sans Thai has no emoji")
|
||||
};
|
||||
assert!(m.contains("Noto Sans Thai") && m.contains("U+1F600"), "the error names the font and the character: {m}");
|
||||
assert_eq!(page_text(&mut a, reopened), edited);
|
||||
let image = a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":144})).unwrap();
|
||||
assert!(image.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
/// `text_prepare` refuses what it can't act on, changing nothing: a page with no grouped text
|
||||
/// (with the reason), and a page the document doesn't have.
|
||||
#[test]
|
||||
fn text_prepare_refuses_pages_without_grouped_text() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::write(dir.join("source.pdf"), fixture(1)).unwrap();
|
||||
let mut a = auto(dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
|
||||
let Err(ToolError::Failed(m)) = a.call("text_prepare", &json!({"doc":doc,"page":1})) else { panic!("nothing to prepare") };
|
||||
assert!(m.contains("no supported grouped text"), "{m}");
|
||||
let Err(ToolError::InvalidArgs(m)) = a.call("text_prepare", &json!({"doc":doc,"page":2})) else { panic!("no page 2") };
|
||||
assert!(m.contains("out of range"), "{m}");
|
||||
assert!(a.call("edit_undo", &json!({"doc":doc})).is_err(), "nothing was recorded to undo");
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["document"]["dirty"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs() {
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
use std::sync::Arc;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
let mut source = Document::open(Arc::new(fixture(2))).unwrap();
|
||||
let old = "ข้อความเดิม น้ำ ผู้ใช้";
|
||||
let new = "ข้อความแก้ไข น้ำ ผู้ใช้";
|
||||
let mut cmap = String::from("begincmap 1 begincodespacerange <00> <ff> endcodespacerange\n");
|
||||
cmap.push_str(&format!("{} beginbfchar\n", old.chars().count()));
|
||||
for (i, ch) in old.chars().enumerate() {
|
||||
cmap.push_str(&format!("<{:02x}> <{:04x}>\n", 65 + i, u32::from(ch)));
|
||||
}
|
||||
cmap.push_str("endbfchar endcmap");
|
||||
let unicode = source.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+LegacyThai"));
|
||||
font.set(b"FirstChar".to_vec(), Object::Int(65));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(65 + old.chars().count() as i64 - 1));
|
||||
font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(300); old.chars().count()]));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(unicode));
|
||||
let font = source.add(Object::Dict(font));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(font));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(300)]));
|
||||
form.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
let codes: String = (0..old.chars().count()).map(|i| format!("{:02x}", 65 + i)).collect();
|
||||
let form = source.add(Object::Stream(Stream::flate(form, format!("BT /F1 14 Tf 20 220 Td <{codes}> Tj ET").as_bytes())));
|
||||
for page in [ObjRef::new(4, 0), ObjRef::new(6, 0)] {
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set(b"Body".to_vec(), Object::Ref(form));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
let content = source.add(Object::Stream(Stream::flate(Dict::new(), b"q /Body Do Q")));
|
||||
source
|
||||
.update_dict(page, |d| {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(content));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(dir);
|
||||
let id = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0);
|
||||
let prepared = ok(&mut a, "text_prepare", json!({"doc":id,"page":1}));
|
||||
assert_eq!(
|
||||
[&prepared["prepared_groups"], &prepared["repaired_fonts"], &prepared["refused_groups"]],
|
||||
[&json!(1), &json!(0), &json!([])],
|
||||
"{prepared}"
|
||||
);
|
||||
assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before, "preparing must not change pixels");
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["paragraphs"][0]["text"], old);
|
||||
// The bundled Thai face is an optional build input. Without it, `builtin:thai` is refused and
|
||||
// the grouped edit, undo, redo and save checks run on Latin text in Helvetica instead.
|
||||
let thai = json!({"doc":id,"page":1,"paragraph":1,"text":new,"font_resource":"builtin:thai"});
|
||||
let (new, args) = if pdfcraft_fonts::document_thai_font().is_some() {
|
||||
(new, thai)
|
||||
} else {
|
||||
let Err(ToolError::Failed(m)) = a.call("text_edit", &thai) else { panic!("builtin:thai needs the bundled Thai face") };
|
||||
assert!(m.contains("builtin:thai"), "{m}");
|
||||
let latin = "Edited body";
|
||||
(latin, json!({"doc":id,"page":1,"paragraph":1,"text":latin,"font":"helvetica"}))
|
||||
};
|
||||
ok(&mut a, "text_edit", args);
|
||||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":id,"page":1}))["lines"][0]["text"].as_str().unwrap().trim(), new);
|
||||
assert_eq!(page_text(&mut a, id)[1], old, "a shared form's other page must not change");
|
||||
let after = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||||
assert_ne!(after, before);
|
||||
ok(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before);
|
||||
ok(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0);
|
||||
ok(&mut a, "edit_redo", json!({"doc":id}));
|
||||
ok(&mut a, "edit_redo", json!({"doc":id}));
|
||||
ok(&mut a, "doc_save", json!({"doc":id,"path":"edited.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), after);
|
||||
assert_eq!(page_text(&mut a, reopened), [new, old]);
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 0, "editing existing text must not create Add Text objects");
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ mod layout;
|
||||
mod store;
|
||||
mod types;
|
||||
|
||||
pub(crate) use draw::thai_glyph_resources;
|
||||
#[cfg(test)]
|
||||
use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped};
|
||||
#[cfg(test)]
|
||||
@@ -87,3 +88,5 @@ fn align_from(code: Option<i64>) -> Align {
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests;
|
||||
#[cfg(test)]
|
||||
mod thai_tests;
|
||||
|
||||
@@ -390,3 +390,55 @@ pub(super) fn params(c: &Content) -> Dict {
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline
|
||||
/// writer instead of trying to embed the matching craft-fonts variable program as a static face.
|
||||
pub(crate) fn thai_glyph_resources(
|
||||
doc: &mut Document,
|
||||
fonts: &mut Dict,
|
||||
shaped: &pdfcraft_fonts::source_shaping::ShapedRun,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, EditError> {
|
||||
if shaped.glyphs.len() > 65_534 {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
let mut outlines = HashMap::new();
|
||||
let mut clusters = Vec::new();
|
||||
let mut index = HashMap::new();
|
||||
let mut indices = Vec::new();
|
||||
for glyph in &shaped.glyphs {
|
||||
let id = glyph.id.to_u32();
|
||||
let key = (ShapedScript::Thai, id);
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) {
|
||||
entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?);
|
||||
}
|
||||
let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| {
|
||||
let at = clusters.len();
|
||||
let width = outlines.get(&key).map_or(0.0, |g| g.width);
|
||||
clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() });
|
||||
at
|
||||
});
|
||||
indices.push(at);
|
||||
}
|
||||
// Validate all outlines before adding resources. Type 3 character codes are single bytes;
|
||||
// shard long rows and return the appropriate resource/code for every positioned glyph.
|
||||
let mut names = Vec::new();
|
||||
for chunk in clusters.chunks(GLYPHS_PER_FONT) {
|
||||
let refs: Vec<_> = chunk.iter().collect();
|
||||
let font = type3_shaped(doc, &refs, &outlines);
|
||||
doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?;
|
||||
let mut name = format!("PCThai{}", font.num);
|
||||
while fonts.contains(name.as_bytes()) {
|
||||
name.push('_');
|
||||
}
|
||||
fonts.set(name.as_bytes().to_vec(), Object::Ref(font));
|
||||
names.push(name);
|
||||
}
|
||||
indices
|
||||
.into_iter()
|
||||
.map(|at| {
|
||||
let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?;
|
||||
let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?;
|
||||
Ok((name.clone(), vec![code]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Existing Thai text drawn from the bundled Thai outlines (`thai_glyph_resources`): one Type 3
|
||||
//! code per distinct glyph and cluster text, in fonts of `GLYPHS_PER_FONT` codes each. The
|
||||
//! outline checks need the craft-fonts Thai face; the budget check runs without it.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document};
|
||||
use pdfcraft_fonts::source_shaping::{ShapedGlyph, ShapedRun};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `count` copies of `glyph`, each with cluster text of its own (so each needs a code of its own).
|
||||
fn distinct(glyph: &ShapedGlyph, count: usize) -> ShapedRun {
|
||||
let glyphs = (0..count).map(|i| ShapedGlyph { position: [i as f64, 0.0], text: format!("{i}"), ..glyph.clone() }).collect();
|
||||
ShapedRun { glyphs, advance: count as f64 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_glyph_resources_refuse_a_row_past_the_glyph_budget() {
|
||||
let glyph = ShapedGlyph { id: Default::default(), position: [0.0, 0.0], text: String::new() };
|
||||
let over = ShapedRun { glyphs: vec![glyph; 65_535], advance: 0.0 };
|
||||
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
|
||||
assert!(thai_glyph_resources(&mut doc, &mut fonts, &over).is_err());
|
||||
assert!(fonts.is_empty(), "a refused row adds no font");
|
||||
}
|
||||
|
||||
/// Codes fill a font up to `GLYPHS_PER_FONT`, the next distinct cluster starts another font at
|
||||
/// code 1, and the same glyph with the same text again reuses its code. One glyph drawn for two
|
||||
/// cluster texts takes two codes, since each code's ToUnicode gives its own text.
|
||||
#[test]
|
||||
fn thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary() {
|
||||
let Some(face) = pdfcraft_fonts::document_thai_font() else {
|
||||
eprintln!(
|
||||
"skipping thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary: built without craft-fonts (set CRAFT_FONTS_DIR to run it)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let ko = pdfcraft_fonts::source_shaping::shape(face.bytes, "ก").unwrap().glyphs.remove(0);
|
||||
for (clusters, fonts_used) in [(GLYPHS_PER_FONT - 1, 1), (GLYPHS_PER_FONT, 1), (GLYPHS_PER_FONT + 1, 2)] {
|
||||
let mut shaped = distinct(&ko, clusters);
|
||||
shaped.glyphs.push(shaped.glyphs[0].clone());
|
||||
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
|
||||
let placed = thai_glyph_resources(&mut doc, &mut fonts, &shaped).unwrap();
|
||||
assert_eq!((fonts.len(), placed.len()), (fonts_used, clusters + 1), "{clusters} clusters");
|
||||
assert_eq!(placed.last(), placed.first(), "a repeated glyph and text reuses its code");
|
||||
let codes: std::collections::HashSet<_> = placed.iter().collect();
|
||||
assert_eq!(codes.len(), clusters, "every distinct cluster has a code of its own");
|
||||
assert_eq!(placed[GLYPHS_PER_FONT - 2].1, [(GLYPHS_PER_FONT - 1) as u8]);
|
||||
if clusters > GLYPHS_PER_FONT {
|
||||
assert_eq!(placed[GLYPHS_PER_FONT - 1].1, [GLYPHS_PER_FONT as u8]);
|
||||
assert_eq!(placed[GLYPHS_PER_FONT].1, [1], "the next font starts at code 1");
|
||||
assert_ne!(placed[GLYPHS_PER_FONT].0, placed[0].0);
|
||||
}
|
||||
}
|
||||
// At the budget: one cluster repeated 65 534 times needs one code.
|
||||
let mut row = distinct(&ko, 1);
|
||||
row.glyphs = vec![row.glyphs[0].clone(); 65_534];
|
||||
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
|
||||
let placed = thai_glyph_resources(&mut doc, &mut fonts, &row).unwrap();
|
||||
assert_eq!((fonts.len(), placed.len()), (1, 65_534));
|
||||
}
|
||||
@@ -49,7 +49,7 @@ pub struct PageImage {
|
||||
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
|
||||
/// operators of the page's streams joined ([`pdfcraft_content::Pieces`]).
|
||||
pub stream: usize,
|
||||
op: usize,
|
||||
pub(crate) op: usize,
|
||||
/// CTM before `Do`, excluding a Form's own Matrix.
|
||||
draw_matrix: [f64; 6],
|
||||
clips: Vec<ClipPath>,
|
||||
@@ -1668,10 +1668,11 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into()));
|
||||
}
|
||||
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op);
|
||||
let space = crate::ungroup::page_blend_space(doc, &p.dict);
|
||||
let (inline, mut merged, patterns) =
|
||||
crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix))?;
|
||||
// One allowance for the form and everything it draws.
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::new(crate::ungroup::MAX_FORM_BYTES, 0);
|
||||
let crate::ungroup::Ungrouped { ops: inline, resources: mut merged, patterns, .. } =
|
||||
crate::ungroup::ungroup_ops(doc, &res, &ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget).map_err(|r| r.error)?;
|
||||
if !patterns.is_empty() {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, copy) in patterns {
|
||||
|
||||
+1114
-1
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ pub use session::TextSession;
|
||||
mod blocks;
|
||||
mod editor;
|
||||
mod fallback;
|
||||
pub mod forms;
|
||||
mod interp;
|
||||
mod lines;
|
||||
mod rewrite;
|
||||
@@ -42,8 +43,8 @@ pub use blocks::{
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
replace_block, text_blocks,
|
||||
};
|
||||
use editor::layout_draft;
|
||||
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
|
||||
use editor::{builtin_thai_choice, layout_draft};
|
||||
#[cfg(test)]
|
||||
pub(crate) use fallback::SOURCE_SPACE_CALLS;
|
||||
use fallback::{
|
||||
|
||||
@@ -407,7 +407,7 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str)
|
||||
pub struct BlockStyle {
|
||||
/// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style.
|
||||
pub ranges: Vec<RangeStyle>,
|
||||
/// Explicit page font resource (from register_text_font or the page font inventory).
|
||||
/// Explicit page font resource or bundled document-font choice from the page font inventory.
|
||||
pub resource: Option<String>,
|
||||
/// A standard font family, bold, italic.
|
||||
pub family: Option<(crate::added::Family, bool, bool)>,
|
||||
|
||||
@@ -2,15 +2,30 @@ 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, Document, EditError, Metrics, Object, TextLine, TextSession};
|
||||
use super::{DecodeBudget, Dict, Document, EditError, Metrics, Object, TextLine, TextSession};
|
||||
|
||||
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
|
||||
/// are never installed, copied into assets, or used as a font for another document.
|
||||
/// 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> {
|
||||
@@ -32,6 +47,7 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
|
||||
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
|
||||
@@ -43,9 +59,26 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
|
||||
continue;
|
||||
}
|
||||
budget -= full.bytes.len();
|
||||
result.push(TextEditorFont { resource: format!("PCApproved{}", full.name()), face: face.clone(), bytes: full.bytes.to_vec() });
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ const HEBREW_FONT_NAME: &str = "PCHebrew";
|
||||
/// The `/Name` of the Type 3 fonts generated from the bundled Latin (Inter) face.
|
||||
const LATIN_FONT_NAME: &str = "PCLatin";
|
||||
|
||||
/// Whether `font` is one of those generated fonts (Latin, Japanese or Hebrew): a replacement longer
|
||||
/// Whether `font` is one of those generated fonts (Latin, Japanese, Hebrew or Thai): a replacement longer
|
||||
/// than one of them is split across several, which read back as one line (see [`same_font`]).
|
||||
pub(super) fn is_type3_fallback(font: &Dict) -> bool {
|
||||
font.name(b"Subtype") == Some(b"Type3")
|
||||
&& font
|
||||
.name(b"Name")
|
||||
.is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes())
|
||||
&& font.name(b"Name").is_some_and(|n| {
|
||||
n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes() || n == b"PCThai"
|
||||
})
|
||||
}
|
||||
|
||||
/// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Make text in ordinary Form XObjects available to the existing paragraph editor.
|
||||
//!
|
||||
//! This replaces each supported placement's `Do`, never the shared form object. The same
|
||||
//! source glyph operators become page content; no cover rectangle or replacement text box is
|
||||
//! added. Unsupported compositing and malformed streams stay intact.
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
use super::{ContentStream, Read, content_streams, joined, page_dict, rewritten_contents, splice};
|
||||
use crate::{EditError, ungroup};
|
||||
|
||||
/// Result of preparing one page. Refused groups remain in the page, with an actionable reason.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Preparation {
|
||||
pub expanded: usize,
|
||||
pub refused: Vec<String>,
|
||||
pub repaired_fonts: usize,
|
||||
}
|
||||
|
||||
/// How many levels of nested groups preparation opens on one page. Text nested deeper stays
|
||||
/// grouped and is reported in [`Preparation::refused`].
|
||||
const MAX_LEVELS: usize = 8;
|
||||
|
||||
/// What preparing one page may decode, and add to its content, in all.
|
||||
const MAX_PREPARED_BYTES: usize = 16 << 20;
|
||||
|
||||
/// What one pass of [`prepare`] inlined, as positions in the next pass's parse of the page.
|
||||
struct Inlined {
|
||||
/// How many operators that parse has; with any other count the positions are wrong.
|
||||
ops: usize,
|
||||
/// Where each inlined form's operators are: the placements in them are new.
|
||||
ranges: Vec<std::ops::Range<usize>>,
|
||||
/// Earlier placements refused only because their resources needed renaming
|
||||
/// ([`crate::ungroup::Refusal::renaming`]), each with the reason it was given.
|
||||
waiting: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
/// Prepare grouped source text atomically, within eight levels and 64 placements per page, and
|
||||
/// repair the page fonts' damaged legacy Thai maps (`repair_thai_fonts`), grouped or not.
|
||||
/// Shared forms and other pages are untouched. Preparation is a distinct undoable engine edit.
|
||||
pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError> {
|
||||
let mut next = doc.clone();
|
||||
let mut result = Preparation::default();
|
||||
let mut bytes_left = MAX_PREPARED_BYTES;
|
||||
let mut visits = 0usize;
|
||||
let mut decode = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0);
|
||||
// Forms found to be artwork, or refused for a reason in the form itself (`Refusal::intrinsic`):
|
||||
// a placement of one that an expanded group adds is left as it is without being read or
|
||||
// counted, so logos drawn inside many text groups don't use up the limit. A form refused only
|
||||
// where it is drawn is read again where an expanded group draws it.
|
||||
let mut settled = std::collections::HashSet::new();
|
||||
// What the previous pass inlined. Only the placements inside it are new. Every other placement
|
||||
// was read and counted on an earlier pass, so a group refused where it is drawn counts once.
|
||||
// Reading one again gives the same answer (an inlined form restores the graphics state it
|
||||
// changes, and the decode budget only shrinks), except for a group refused because its
|
||||
// resources needed renaming: names merged in for other groups since can make that
|
||||
// unnecessary. Such a group is read again, uncounted, once its resource names say so.
|
||||
let mut inlined: Option<Inlined> = None;
|
||||
// One pass opens one level of every placement; the last pass only reports what is deeper.
|
||||
for pass in 0..=MAX_LEVELS {
|
||||
let p = page_dict(&next, page)?;
|
||||
let streams = strict_streams(&next, &p.dict)?;
|
||||
let pieces = joined(&streams);
|
||||
let parsed = pieces.parse();
|
||||
if parsed.skipped > 0 {
|
||||
return Err(EditError::Invalid("the page has unreadable content; grouped text was left unchanged".into()));
|
||||
}
|
||||
let ops = parsed.ops;
|
||||
// `None` reads every placement: on the first pass, and if the page didn't parse back to
|
||||
// the operators written, where the positions can't be trusted. Reading one again can only
|
||||
// count it twice; skipping one could leave its text grouped without a reason.
|
||||
let (new, mut waiting) = match inlined.take() {
|
||||
Some(i) if i.ops == ops.len() => (Some(i.ranges), i.waiting),
|
||||
_ => (None, Vec::new()),
|
||||
};
|
||||
let mut still_waiting = Vec::new();
|
||||
let resources = p.dict.get(b"Resources").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let space = ungroup::page_blend_space(&next, &p.dict);
|
||||
let images: std::collections::BTreeMap<_, _> =
|
||||
crate::page_images(&next, page)?.into_iter().filter(|i| i.is_form).map(|i| (i.op, i)).collect();
|
||||
let mut replacements = std::collections::BTreeMap::new();
|
||||
let mut merged = resources;
|
||||
let (mut refused_here, mut expanded_here) = (std::collections::HashSet::new(), std::collections::HashSet::new());
|
||||
for (at, op) in ops.iter().enumerate() {
|
||||
let Some(image) = images.get(&at) else { continue };
|
||||
if image.object.is_some_and(|o| settled.contains(&o)) {
|
||||
continue;
|
||||
}
|
||||
// An earlier placement is read again only when it was refused for a rename that the
|
||||
// names merged since have made unnecessary. The last pass opens nothing, so there it
|
||||
// keeps its reason.
|
||||
let earlier = new.as_ref().is_some_and(|ranges| !ranges.iter().any(|r| r.contains(&at)));
|
||||
if earlier {
|
||||
let Some(w) = waiting.iter().position(|(i, _)| *i == at) else { continue };
|
||||
let (_, reason) = waiting.swap_remove(w);
|
||||
// The name exactly as the `Do` writes it: a name need not be UTF-8, and
|
||||
// `image.name` is only its display form.
|
||||
if pass == MAX_LEVELS || !op.last_name().is_some_and(|name| ungroup::merges_without_renaming(&next, &merged, name)) {
|
||||
still_waiting.push((at, reason));
|
||||
continue;
|
||||
}
|
||||
// What reading it again finds replaces the reason it was given.
|
||||
if let Some(i) = result.refused.iter().position(|r| *r == reason) {
|
||||
result.refused.remove(i);
|
||||
}
|
||||
}
|
||||
if !earlier {
|
||||
visits = visits.saturating_add(1);
|
||||
if visits > 64 {
|
||||
return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into()));
|
||||
}
|
||||
}
|
||||
match ungroup::ungroup_ops(&next, &merged, &ops, at, space.as_ref(), Matrix(image.matrix), &mut decode) {
|
||||
Ok(ungroup::Ungrouped { ops: inline, resources: mut res, patterns, shows_text }) => {
|
||||
// Vector and image artwork needs no preparation. Nested text groups are
|
||||
// visited on the next pass, with their inherited placement and clip in force.
|
||||
match shows_text {
|
||||
Some(true) => {}
|
||||
Some(false) => {
|
||||
settled.extend(image.object);
|
||||
continue;
|
||||
}
|
||||
// Whether it has text can't be known: it is left as it is, and said.
|
||||
None => {
|
||||
result.refused.push(format!("{}: it draws recursive forms, or nested content that can't be checked", image.name));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if pass == MAX_LEVELS {
|
||||
result.refused.push(format!("{}: its text is grouped more than {MAX_LEVELS} levels deep", image.name));
|
||||
continue;
|
||||
}
|
||||
let data_len = pdfcraft_content::serialize_ops(&inline).len();
|
||||
bytes_left = bytes_left.checked_sub(data_len).ok_or_else(|| {
|
||||
EditError::Invalid("grouped page content exceeds the 16 MiB editing budget; the page was left unchanged".into())
|
||||
})?;
|
||||
if !patterns.is_empty() {
|
||||
let mut table = res.get(b"Pattern").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, value) in patterns {
|
||||
table.set(name, Object::Ref(next.add(value)));
|
||||
}
|
||||
res.set(b"Pattern".to_vec(), Object::Dict(table));
|
||||
}
|
||||
merged = res;
|
||||
replacements.insert(at, inline);
|
||||
result.expanded += 1;
|
||||
expanded_here.extend(image.object);
|
||||
}
|
||||
Err(refusal) => {
|
||||
let reason = format!("{}: {}", image.name, refusal.error);
|
||||
if refusal.renaming {
|
||||
still_waiting.push((at, reason.clone()));
|
||||
}
|
||||
if refusal.intrinsic {
|
||||
refused_here.extend(image.object);
|
||||
}
|
||||
result.refused.push(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
settled.extend(refused_here.difference(&expanded_here).copied());
|
||||
if replacements.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Where an operator lands in the next pass: each replaced `Do` before it becomes its
|
||||
// inlined operators.
|
||||
let moved = |i: usize| replacements.range(..i).fold(i, |n, (_, inline)| n.saturating_add(inline.len()).saturating_sub(1));
|
||||
inlined = Some(Inlined {
|
||||
ops: moved(ops.len()),
|
||||
ranges: replacements.iter().map(|(at, inline)| moved(*at)..moved(*at).saturating_add(inline.len())).collect(),
|
||||
waiting: still_waiting.into_iter().map(|(at, reason)| (moved(at), reason)).collect(),
|
||||
});
|
||||
let changed = splice(&pieces, &ops, |at| match replacements.remove(&at) {
|
||||
Some(ops) => (ops, false),
|
||||
None => (Vec::new(), true),
|
||||
});
|
||||
let contents = rewritten_contents(&mut next, &streams, changed)?;
|
||||
next.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), contents);
|
||||
d.set(b"Resources".to_vec(), Object::Dict(merged));
|
||||
})?;
|
||||
}
|
||||
result.refused.sort();
|
||||
result.refused.dedup();
|
||||
// The page's own damaged maps are repaired too, those of text that was never grouped
|
||||
// included: its text reads wrong all the same.
|
||||
result.repaired_fonts = repair_thai_fonts(&mut next, page)?;
|
||||
if result.expanded > 0 || result.repaired_fonts > 0 {
|
||||
for reason in &result.refused {
|
||||
next.note_repair(format!("Grouped text left intact: {reason}"));
|
||||
}
|
||||
if result.expanded > 0 {
|
||||
next.note_repair(format!(
|
||||
"Prepared {} grouped text placement(s) on page {} for direct source-text editing",
|
||||
result.expanded,
|
||||
page.saturating_add(1)
|
||||
));
|
||||
}
|
||||
*doc = next;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Whether grouped artwork `image` on `page` (its index in `page_images`) shows text that
|
||||
/// [`prepare`] would make editable, read as its first pass reads it, without changing anything:
|
||||
/// a logo or a refused group is `false`. A group refused only because its resources need renaming
|
||||
/// is `true`, as another group's names can still let [`prepare`] open it.
|
||||
pub fn grouped_text_at(doc: &Document, page: usize, image: usize) -> Result<bool, EditError> {
|
||||
let Some(img) = crate::page_images(doc, page)?.into_iter().nth(image).filter(|i| i.is_form) else { return Ok(false) };
|
||||
let p = page_dict(doc, page)?;
|
||||
let parsed = joined(&strict_streams(doc, &p.dict)?).parse();
|
||||
if parsed.skipped > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let space = ungroup::page_blend_space(doc, &p.dict);
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0);
|
||||
Ok(match ungroup::ungroup_ops(doc, &resources, &parsed.ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget) {
|
||||
Ok(u) => u.shows_text == Some(true),
|
||||
Err(r) => r.renaming,
|
||||
})
|
||||
}
|
||||
|
||||
/// A font is copied into this page: repairing the map must never alter a shared form/font.
|
||||
fn repair_thai_fonts(doc: &mut Document, page: usize) -> Result<usize, EditError> {
|
||||
let p = page_dict(doc, page)?;
|
||||
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut count = 0;
|
||||
let mut candidates = 0usize;
|
||||
for (name, value) in fonts.clone().iter() {
|
||||
let Some(mut font) = doc.resolve(value).as_dict().cloned() else { continue };
|
||||
if font.name(b"BaseFont").is_some_and(|n| String::from_utf8_lossy(n).contains("THSarabunPSK")) {
|
||||
candidates += 1;
|
||||
if candidates > 64 {
|
||||
return Err(EditError::Invalid("too many legacy Thai font maps to repair safely; the page was left unchanged".into()));
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
let Some(map) = pdfcraft_fonts::pdf::recovered_thai_map(doc, &font) else { continue };
|
||||
// Append corrective bfchar entries after the original entries so valid Latin mappings
|
||||
// remain. The original map's accidental oversized range is still overridden for Thai.
|
||||
let Some(Object::Stream(tu)) = font.get(b"ToUnicode").map(|r| doc.resolve(r)).as_deref().cloned() else { continue };
|
||||
let old = pdfcraft_cos::DecodeBudget::new(1 << 20, 0).decode_strict(doc, &tu)?;
|
||||
// After the last `endcmap`: a stream with more than one must not have an earlier block
|
||||
// (or its oversized range) after the corrective entries.
|
||||
let marker = b"endcmap";
|
||||
let Some(at) = old.windows(marker.len()).rposition(|w| w == marker) else { continue };
|
||||
let mut added = format!("\n{} beginbfchar\n", map.len());
|
||||
for (code, ch) in map {
|
||||
added.push_str(&format!("<{code:04x}> <{:04x}>\n", u32::from(ch)));
|
||||
}
|
||||
added.push_str("endbfchar\n");
|
||||
let mut bytes = old.get(..at).unwrap_or_default().to_vec();
|
||||
bytes.extend_from_slice(added.as_bytes());
|
||||
bytes.extend_from_slice(old.get(at..).unwrap_or_default());
|
||||
let tu = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &bytes)));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(tu));
|
||||
let object = doc.add(Object::Dict(font));
|
||||
fonts.set(name.clone(), Object::Ref(object));
|
||||
count += 1;
|
||||
}
|
||||
if count > 0 {
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
doc.note_repair(format!(
|
||||
"Recovered the damaged legacy THSarabunPSK Thai character map in {count} page font(s); original glyph programs were preserved"
|
||||
));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Editing cannot infer the state after a damaged page stream. Every piece must decode whole
|
||||
/// before a placement is expanded, rather than treating an undecodable piece as empty.
|
||||
fn strict_streams(doc: &Document, page: &Dict) -> Result<Vec<ContentStream>, EditError> {
|
||||
let streams = content_streams(doc, page)?;
|
||||
if streams.iter().any(|s| s.read != Read::Whole) {
|
||||
return Err(EditError::Invalid("the page has a missing or unreadable content stream".into()));
|
||||
}
|
||||
Ok(streams)
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
|
||||
no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family,
|
||||
source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font,
|
||||
source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph,
|
||||
union, write_type3_glyphs,
|
||||
};
|
||||
|
||||
#[path = "text_regions/grouping.rs"]
|
||||
|
||||
@@ -2,9 +2,9 @@ use super::super::interp::TextState;
|
||||
use super::grouping::{across, is_centred, row_rect};
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
|
||||
no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width,
|
||||
splice, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program,
|
||||
source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
};
|
||||
|
||||
// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces.
|
||||
@@ -104,9 +104,46 @@ impl FontResolver<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
struct LegacyFace {
|
||||
/// Shared by every resource name of one font object: its program is decoded and kept once.
|
||||
glyphs: Option<std::rc::Rc<pdfcraft_fonts::pdf::LegacyThaiFont>>,
|
||||
metrics: Metrics,
|
||||
/// Weight and slant ([`FontStyle::of`]).
|
||||
style: FontStyle,
|
||||
/// The font object the resource names, when it is given by reference.
|
||||
object: Option<pdfcraft_cos::ObjRef>,
|
||||
/// Its text-space fallback ([`source_space`]), in ems.
|
||||
space: f64,
|
||||
}
|
||||
|
||||
/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets
|
||||
/// (each at most 16 MiB): a page may name many of them.
|
||||
const MAX_THAI_PROGRAMS: usize = 64 << 20;
|
||||
|
||||
struct PreparedCharacters {
|
||||
values: Vec<StyledChar>,
|
||||
substituted: Option<String>,
|
||||
thai: ThaiShaping,
|
||||
}
|
||||
|
||||
/// How the Thai spans of an edit are shaped, by the font name their characters carry.
|
||||
#[derive(Default)]
|
||||
struct ThaiShaping {
|
||||
/// Draft-only names drawn with the bundled Thai outlines.
|
||||
outlined: std::collections::HashSet<String>,
|
||||
/// Source faces shaped in their existing codes, by resource name.
|
||||
legacy: HashMap<String, LegacyFace>,
|
||||
/// Draft-only names whose rows, Latin ones included, are shaped and embedded with the
|
||||
/// complete program of this same-face resource: a span its subsets can't show in place.
|
||||
program: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// How a Thai span keeps its source face ([`legacy_thai_font`]).
|
||||
enum ThaiSpan {
|
||||
/// In this resource's existing codes.
|
||||
InPlace(String),
|
||||
/// Shaped by this same-face resource's complete program with its own layout.
|
||||
Program(String),
|
||||
}
|
||||
|
||||
struct CharacterPreparation<'a> {
|
||||
@@ -125,12 +162,93 @@ struct CharacterPreparation<'a> {
|
||||
impl CharacterPreparation<'_> {
|
||||
fn prepare(self) -> Result<PreparedCharacters, EditError> {
|
||||
let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self;
|
||||
let thai_choice = builtin_thai_choice(fonts);
|
||||
let mut thai = ThaiShaping::default();
|
||||
let mut thai_programs = pdfcraft_cos::DecodeBudget::new(MAX_THAI_PROGRAMS, 0);
|
||||
let mut chars = Vec::new();
|
||||
let mut substituted = None;
|
||||
let mut fallback = FallbackBuilder::new(max_glyphs);
|
||||
let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() };
|
||||
for (range, index) in spans {
|
||||
let source = lines.get(index).unwrap_or(first);
|
||||
let span = draft.get(range.clone()).unwrap_or_default();
|
||||
// Keep the exact source face. A different Thai face requires an explicit choice.
|
||||
// Interface fallback fonts never enter a PDF.
|
||||
let selected_resource = overrides.get(&index).and_then(|r| r.resource.as_deref()).or(style.resource.as_deref());
|
||||
let use_thai_outlines = if selected_resource == Some(thai_choice.as_str()) {
|
||||
let face = pdfcraft_fonts::document_thai_font().ok_or_else(|| {
|
||||
EditError::Invalid(format!(
|
||||
"the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists"
|
||||
))
|
||||
})?;
|
||||
if !pdfcraft_fonts::embedding::covers(face.bytes, span) {
|
||||
let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
|
||||
return Err(EditError::Invalid(match missing {
|
||||
Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)),
|
||||
None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family),
|
||||
}));
|
||||
}
|
||||
substituted = Some(face.family.to_owned());
|
||||
true
|
||||
} else {
|
||||
selected_resource.is_none() && matching_thai_outline(resolver.doc, resolver.fonts, source, span)
|
||||
};
|
||||
if use_thai_outlines {
|
||||
substituted = pdfcraft_fonts::document_thai_font().map(|f| f.family.to_owned());
|
||||
}
|
||||
let existing_thai = if !use_thai_outlines {
|
||||
let chosen = match selected_resource {
|
||||
None => Some(source.clone()),
|
||||
Some(resource) => resolver.fonts.get(resource.as_bytes()).and_then(|f| resolver.doc.dict(f)).map(|font| {
|
||||
let mut chosen = source.clone();
|
||||
chosen.font = resource.to_owned();
|
||||
chosen.base_font = Metrics::from_dict(resolver.doc, &font).base_font;
|
||||
chosen
|
||||
}),
|
||||
};
|
||||
match chosen {
|
||||
Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut thai.legacy, &mut thai_programs)?,
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let outline_name = if use_thai_outlines {
|
||||
// A draft-only name prevents this choice from affecting another style span that
|
||||
// explicitly selects the original resource. It is never saved into the PDF.
|
||||
let mut name = format!("PCOutlineThai{index}");
|
||||
while resolver.metrics.contains_key(&name) {
|
||||
name.push('_');
|
||||
}
|
||||
if let Some(m) = resolver
|
||||
.metrics
|
||||
.get(&source.font)
|
||||
.or_else(|| source.origin.state.font.as_ref().and_then(|(name, _)| resolver.metrics.get(name)))
|
||||
.cloned()
|
||||
{
|
||||
resolver.metrics.insert(name.clone(), m);
|
||||
}
|
||||
thai.outlined.insert(name.clone());
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (existing_thai, program_name) = match existing_thai {
|
||||
Some(ThaiSpan::InPlace(name)) => (Some(name), None),
|
||||
Some(ThaiSpan::Program(resource)) => {
|
||||
// Draft-only too: another span may still show this resource's existing codes.
|
||||
let mut name = format!("PCProgramThai{index}");
|
||||
while resolver.metrics.contains_key(&name) {
|
||||
name.push('_');
|
||||
}
|
||||
if let Some(m) = resolver.metrics.get(&source.font).cloned() {
|
||||
resolver.metrics.insert(name.clone(), m);
|
||||
}
|
||||
thai.program.insert(name.clone(), resource);
|
||||
(None, Some(name))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
let start = range.start;
|
||||
for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() {
|
||||
if ch == '\n' {
|
||||
@@ -144,7 +262,11 @@ impl CharacterPreparation<'_> {
|
||||
}
|
||||
let text = ch.to_string();
|
||||
let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref());
|
||||
let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? {
|
||||
let name = if let Some(name) = outline_name.as_ref().or(program_name.as_ref()) {
|
||||
name.clone()
|
||||
} else if let Some(name) = &existing_thai {
|
||||
name.clone()
|
||||
} else if let Some(name) = resolver.encodable_font(source, &text, requested)? {
|
||||
name
|
||||
} else if needs_type3(&text) {
|
||||
fallback.prepare(source, ch, &text, chars.len())?
|
||||
@@ -173,7 +295,7 @@ impl CharacterPreparation<'_> {
|
||||
}
|
||||
substituted = Some(written.family.to_owned());
|
||||
}
|
||||
Ok(PreparedCharacters { values: chars, substituted })
|
||||
Ok(PreparedCharacters { values: chars, substituted, thai })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +355,16 @@ struct StyledLines<'a> {
|
||||
type StyledChar = (char, usize, String);
|
||||
type StyledRows = Vec<Vec<StyledChar>>;
|
||||
type RowRanges = Vec<std::ops::Range<usize>>;
|
||||
type ShapedRunCache = HashMap<(usize, usize), (Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>;
|
||||
enum ShapedFont {
|
||||
Existing(Vec<(String, Vec<u8>)>),
|
||||
Program { bytes: Vec<u8>, family: String },
|
||||
ThaiOutlines,
|
||||
}
|
||||
struct ShapedText {
|
||||
run: pdfcraft_fonts::source_shaping::ShapedRun,
|
||||
font: ShapedFont,
|
||||
}
|
||||
type ShapedRunCache = HashMap<(usize, usize), ShapedText>;
|
||||
|
||||
struct ShapedRows {
|
||||
runs: ShapedRunCache,
|
||||
@@ -243,6 +374,7 @@ struct ShapedRows {
|
||||
fn prepare_shaped_rows(
|
||||
doc: &Document,
|
||||
fonts: &Dict,
|
||||
thai: &ThaiShaping,
|
||||
rows: &[Vec<StyledChar>],
|
||||
kept: &[bool],
|
||||
advance: impl Fn(&StyledChar) -> f64,
|
||||
@@ -259,8 +391,8 @@ fn prepare_shaped_rows(
|
||||
let segment = row.get(at..end).unwrap_or_default();
|
||||
advances.insert((row_index, at), segment.iter().map(&advance).sum::<f64>());
|
||||
let text: String = segment.iter().map(|character| character.0).collect();
|
||||
if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? {
|
||||
runs.insert((row_index, at), (bytes, shaped, family));
|
||||
if let Some(shaped) = shape_source(doc, fonts, thai, &item.2, &text)? {
|
||||
runs.insert((row_index, at), shaped);
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
@@ -268,16 +400,138 @@ fn prepare_shaped_rows(
|
||||
Ok(ShapedRows { runs, advances })
|
||||
}
|
||||
|
||||
fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result<HashMap<(usize, usize), String>, EditError> {
|
||||
type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec<u8>)>>;
|
||||
|
||||
fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result<ShapedResources, EditError> {
|
||||
let mut resources = HashMap::new();
|
||||
for (key, (bytes, shaped, family)) in runs {
|
||||
let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect();
|
||||
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
|
||||
resources.insert(*key, resource);
|
||||
for (key, shaped) in runs {
|
||||
let glyphs = match &shaped.font {
|
||||
ShapedFont::Existing(codes) => codes.clone(),
|
||||
ShapedFont::ThaiOutlines => crate::added::thai_glyph_resources(doc, fonts, &shaped.run)?,
|
||||
ShapedFont::Program { bytes, family } => {
|
||||
let mappings: Vec<_> = shaped.run.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect();
|
||||
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
|
||||
(0..shaped.run.glyphs.len())
|
||||
.map(|i| {
|
||||
let code = u16::try_from(i.saturating_add(1)).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
|
||||
Ok((resource.clone(), code.to_be_bytes().to_vec()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, EditError>>()?
|
||||
}
|
||||
};
|
||||
resources.insert(*key, glyphs);
|
||||
}
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
/// A positioned glyph run, kept in one text object across Type 3 font shards.
|
||||
struct ShapedRunDrawing<'a> {
|
||||
doc: &'a Document,
|
||||
fonts: &'a Dict,
|
||||
shaped: &'a pdfcraft_fonts::source_shaping::ShapedRun,
|
||||
resources: &'a [(String, Vec<u8>)],
|
||||
text: &'a str,
|
||||
matrix: [f64; 6],
|
||||
size: f64,
|
||||
thai: bool,
|
||||
}
|
||||
|
||||
impl ShapedRunDrawing<'_> {
|
||||
/// Draw and return the run's advance in text space, including cluster-safe spacing.
|
||||
fn draw(&self, block_ops: &mut Vec<Op>, state: &mut TextState) -> Result<f64, EditError> {
|
||||
let Self { doc, fonts, shaped, resources, text, matrix: tm, size, thai } = *self;
|
||||
let n = pdfcraft_content::num;
|
||||
let (resource, _) = self.resources.first().ok_or_else(|| EditError::Invalid("shaped run has no glyphs".into()))?;
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(text)));
|
||||
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
|
||||
state.font = Some((resource.clone(), size));
|
||||
// One text object for the run: each glyph is placed where shaping put it by a TJ
|
||||
// adjustment from where the glyph before it ends (its advance in the embedded
|
||||
// font's widths), and raised by the text rise for a mark set above or below
|
||||
// (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode;
|
||||
// ToUnicode and ActualText keep the logical text.
|
||||
let per_unit = 1000.0 / size;
|
||||
if !per_unit.is_finite() {
|
||||
return Err(EditError::Invalid("the shaped text has no size".into()));
|
||||
}
|
||||
let mut shown_fonts = HashMap::new();
|
||||
for (name, _) in resources {
|
||||
if !shown_fonts.contains_key(name) {
|
||||
let font = fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
shown_fonts.insert(name.clone(), Metrics::from_dict(doc, &font));
|
||||
}
|
||||
}
|
||||
// Keep the original text settings. TJ compensates for the automatic PDF advance
|
||||
// after each glyph, while shaping keeps marks attached to their cluster.
|
||||
let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise);
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.extend(state.ops());
|
||||
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
|
||||
// `pen`: where the next glyph would start, in thousandths of the font size.
|
||||
let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise);
|
||||
let mut shown_glyphs: Vec<Object> = Vec::new();
|
||||
let mut cluster_spacing = 0.0;
|
||||
let mut current_resource = resource.as_str();
|
||||
for (i, glyph) in shaped.glyphs.iter().enumerate() {
|
||||
let (name, code) = resources.get(i).ok_or_else(|| EditError::Invalid("shaped glyph resource is missing".into()))?;
|
||||
let shown = shown_fonts.get(name).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
if name != current_resource {
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))]));
|
||||
}
|
||||
block_ops.push(Op::new("Tf", vec![Object::name(name), n(size)]));
|
||||
current_resource = name;
|
||||
}
|
||||
if thai && !glyph.text.is_empty() {
|
||||
// Apply spacing only after a whole cluster, keeping marks on their base.
|
||||
extra += cluster_spacing;
|
||||
cluster_spacing = char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
let target = (glyph.position[0] * size + extra) * per_unit;
|
||||
let glyph_rise = rise + glyph.position[1] * size;
|
||||
if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 {
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))]));
|
||||
}
|
||||
if (glyph_rise - raised).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(glyph_rise)]));
|
||||
raised = glyph_rise;
|
||||
}
|
||||
}
|
||||
if (target - pen).abs() > 1e-6 {
|
||||
shown_glyphs.push(n(pen - target));
|
||||
}
|
||||
shown_glyphs.push(Object::String(PdfString::literal(code.clone())));
|
||||
let cid = code.iter().fold(0_u32, |value, byte| value.saturating_mul(256).saturating_add(u32::from(*byte)));
|
||||
pen = target
|
||||
+ if code.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
shown.width(cid) * 1000.0 + (char_spacing + if shown.is_space(cid, code.len()) { word_spacing } else { 0.0 }) * per_unit
|
||||
};
|
||||
if !thai {
|
||||
extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
}
|
||||
let end = (shaped.advance * size + extra + cluster_spacing) * per_unit;
|
||||
if (end - pen).abs() > 1e-6 {
|
||||
shown_glyphs.push(n(pen - end));
|
||||
}
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)]));
|
||||
}
|
||||
if (raised - rise).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(rise)]));
|
||||
}
|
||||
block_ops.push(Op::new("ET", vec![]));
|
||||
(state.char_spacing, state.word_spacing) = (char_spacing, word_spacing);
|
||||
extra += cluster_spacing;
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
Ok(shaped.advance * size + extra)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_contextual_rows(rows: &[Vec<StyledChar>], kept: &[bool]) -> Result<(), EditError> {
|
||||
for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) {
|
||||
let text: String = row.iter().map(|character| character.0).collect();
|
||||
@@ -595,35 +849,385 @@ fn apply_range_styles<'a>(
|
||||
StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides }
|
||||
}
|
||||
|
||||
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
|
||||
fn shape_source(
|
||||
/// Select one existing subset of the exact source face for a Thai span. Never replace its
|
||||
/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. The
|
||||
/// subsets' programs are read within `budget`, shared by the edit's spans.
|
||||
fn legacy_thai_font(
|
||||
doc: &Document,
|
||||
fonts: &Dict,
|
||||
name: &str,
|
||||
text: &str,
|
||||
) -> Result<Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> {
|
||||
source: &TextLine,
|
||||
span: &str,
|
||||
cache: &mut HashMap<String, LegacyFace>,
|
||||
budget: &mut pdfcraft_cos::DecodeBudget,
|
||||
) -> Result<Option<ThaiSpan>, EditError> {
|
||||
if !source.base_font.rsplit('+').next().unwrap_or(&source.base_font).starts_with("THSarabunPSK")
|
||||
|| !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
|
||||
let mut candidates = vec![source.font.clone()];
|
||||
candidates.extend(fonts.iter().filter_map(|(name, font)| {
|
||||
let font = doc.dict(font)?;
|
||||
(pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned())
|
||||
}));
|
||||
for name in candidates.into_iter().take(128) {
|
||||
if cache.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let Some(resource) = fonts.get(name.as_bytes()) else { continue };
|
||||
let Some(font) = doc.dict(resource) else { continue };
|
||||
let object = resource.as_ref();
|
||||
let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) {
|
||||
Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias },
|
||||
None => {
|
||||
let metrics = Metrics::from_dict(doc, &font);
|
||||
let space = source_space(doc, fonts, &metrics);
|
||||
let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new);
|
||||
let style = FontStyle::of(doc, &font, &metrics);
|
||||
LegacyFace { glyphs, metrics, style, object, space }
|
||||
}
|
||||
};
|
||||
cache.insert(name, face);
|
||||
}
|
||||
// A subset left unread would count as unusable, silently changing which subset draws the text.
|
||||
if budget.remaining() == 0 {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text",
|
||||
source.base_font.rsplit('+').next().unwrap_or(&source.base_font),
|
||||
MAX_THAI_PROGRAMS >> 20
|
||||
)));
|
||||
}
|
||||
// A face none of whose subsets can be shaped in place (no Thai, or neither positioned nor in
|
||||
// the legacy glyph order) is shaped like any other source font (`shape_source`).
|
||||
if !has_thai_source_face(cache, &family) {
|
||||
return Ok(None);
|
||||
}
|
||||
let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty());
|
||||
for row in rows() {
|
||||
if let Err(error) = shape_existing_thai(cache, &source.font, row) {
|
||||
// Not in the face's existing codes. A complete program of the face that shapes every
|
||||
// row with its own layout (one Load font added as CFF, or whose contextual glyphs it
|
||||
// gave no code yet) takes the whole span, so no cluster is split between fonts.
|
||||
let Some(source_font) = fonts.get(source.font.as_bytes()).and_then(|f| doc.dict(f)) else { return Err(error) };
|
||||
let complete = program_candidates(doc, fonts, &source.font, source_font, &source.base_font)
|
||||
.into_iter()
|
||||
.find(|(_, font)| rows().all(|row| shape_with_program(doc, font, row).is_some()));
|
||||
return match complete {
|
||||
Some((name, _)) => Ok(Some(ThaiSpan::Program(name))),
|
||||
None => Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(Some(ThaiSpan::InPlace(source.font.clone())))
|
||||
}
|
||||
|
||||
/// Whether a subset of the face `family` (a [`face_key`](pdfcraft_fonts::embedding::face_key))
|
||||
/// in `cache` can be shaped in place: a THSarabunPSK program with its own Thai mark positioning,
|
||||
/// or a verified legacy positional one.
|
||||
fn has_thai_source_face(cache: &HashMap<String, LegacyFace>, family: &str) -> bool {
|
||||
cache.values().any(|face| face.glyphs.is_some() && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family)
|
||||
}
|
||||
|
||||
/// A visual row may use several subsets of the same exact face (often a separate subset for
|
||||
/// punctuation). Shape Thai clusters intact, and retain each glyph's original PDF font/code.
|
||||
fn shape_existing_thai(cache: &HashMap<String, LegacyFace>, primary: &str, text: &str) -> Result<ShapedText, EditError> {
|
||||
if text.len() > 262_144 {
|
||||
return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into()));
|
||||
}
|
||||
let first = cache.get(primary).ok_or_else(|| EditError::Invalid("the Thai source face is missing".into()))?;
|
||||
let family = pdfcraft_fonts::embedding::face_key(&first.metrics.base_font);
|
||||
let mut candidates = vec![(primary, first)];
|
||||
candidates.extend(
|
||||
cache
|
||||
.iter()
|
||||
.filter(|(name, face)| {
|
||||
name.as_str() != primary && face.style == first.style && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family
|
||||
})
|
||||
.map(|(name, face)| (name.as_str(), face)),
|
||||
);
|
||||
// Stable source-first choice, then resource name; never depend on hash iteration order.
|
||||
if let Some(tail) = candidates.get_mut(1..) {
|
||||
tail.sort_by_key(|(name, _)| *name);
|
||||
}
|
||||
let thai = |c: char| matches!(u32::from(c), 0x0e01..=0x0e5b | 0xf700..=0xf71a);
|
||||
let mut run = pdfcraft_fonts::source_shaping::ShapedRun { glyphs: Vec::new(), advance: 0.0 };
|
||||
let mut resources = Vec::new();
|
||||
let mut chars = text.char_indices().peekable();
|
||||
while let Some((at, ch)) = chars.next() {
|
||||
if thai(ch) {
|
||||
let mut end = at + ch.len_utf8();
|
||||
while let Some(&(next, c)) = chars.peek() {
|
||||
if !thai(c) {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
end = next + c.len_utf8();
|
||||
}
|
||||
let segment = text.get(at..end).ok_or_else(|| EditError::Invalid("invalid Thai character boundary".into()))?;
|
||||
let unshaped = |why: String| {
|
||||
EditError::Invalid(format!(
|
||||
"{} cannot keep this edit in the original face: {}; load the complete original font or explicitly choose a replacement",
|
||||
first.metrics.base_font,
|
||||
if why.is_empty() { "its source glyph program is incomplete" } else { &why }
|
||||
))
|
||||
};
|
||||
// The whole run in one subset keeps any kerning between its clusters. A subsetter may
|
||||
// instead have split the run's glyphs across subsets of the face: then each cluster
|
||||
// (a base and the marks on it, which shape together) comes from a subset that has it.
|
||||
let pieces = match shape_in_one_subset(&candidates, segment) {
|
||||
Ok(whole) => vec![whole],
|
||||
Err(_) => thai_clusters(segment)
|
||||
.into_iter()
|
||||
.map(|cluster| shape_in_one_subset(&candidates, cluster).map_err(unshaped))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
};
|
||||
for (name, shaped) in pieces {
|
||||
for (mut glyph, code) in shaped.shaped.glyphs.into_iter().zip(shaped.codes) {
|
||||
glyph.position[0] += run.advance;
|
||||
resources.push((name.to_owned(), code));
|
||||
run.glyphs.push(glyph);
|
||||
}
|
||||
run.advance += shaped.shaped.advance;
|
||||
}
|
||||
} else {
|
||||
let logical = ch.to_string();
|
||||
let found = candidates.iter().find_map(|(name, face)| {
|
||||
let code = face.metrics.encode(&logical)?;
|
||||
let first = face.metrics.codes(&code).first()?.0;
|
||||
// `encode` accepts a CID the subset emptied (#1443): drawing it would show nothing.
|
||||
// Try the next subset; a space then falls back to its measured gap below.
|
||||
if face.metrics.has_outline(first) == Some(false) {
|
||||
return None;
|
||||
}
|
||||
Some((*name, code, face.metrics.width(first)))
|
||||
});
|
||||
let (name, code, width) = if let Some(found) = found {
|
||||
found
|
||||
} else if ch == ' ' {
|
||||
// The source subset may omit spaces. Use a gap measured from a matching
|
||||
// document subset, then the face's existing text-space fallback.
|
||||
let width = candidates
|
||||
.iter()
|
||||
.find_map(|(_, f)| {
|
||||
let code = f.metrics.encode(" ")?;
|
||||
Some(f.metrics.width(f.metrics.codes(&code).first()?.0))
|
||||
})
|
||||
.unwrap_or(first.space);
|
||||
(primary, Vec::new(), width)
|
||||
} else {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"{} has no source glyph for {ch} (U+{:04X}); load the complete original face or explicitly select a replacement",
|
||||
first.metrics.base_font,
|
||||
u32::from(ch)
|
||||
)));
|
||||
};
|
||||
run.glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph { id: Default::default(), position: [run.advance, 0.0], text: logical });
|
||||
resources.push((name.to_owned(), code));
|
||||
run.advance += width;
|
||||
}
|
||||
if run.glyphs.len() > 65_534 || !run.advance.is_finite() {
|
||||
return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into()));
|
||||
}
|
||||
}
|
||||
Ok(ShapedText { run, font: ShapedFont::Existing(resources) })
|
||||
}
|
||||
|
||||
/// Shape `text` in the first candidate subset that has every glyph it needs, and name it. The
|
||||
/// error is the first subset's reason (empty when no candidate has a usable glyph program).
|
||||
fn shape_in_one_subset<'a>(candidates: &[(&'a str, &LegacyFace)], text: &str) -> Result<(&'a str, pdfcraft_fonts::pdf::ExistingThaiRun), String> {
|
||||
let mut why = String::new();
|
||||
for (name, face) in candidates {
|
||||
let Some(font) = &face.glyphs else { continue };
|
||||
match font.shape(text) {
|
||||
Ok(shaped) => return Ok((name, shaped)),
|
||||
Err(e) if why.is_empty() => why = e,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Err(why)
|
||||
}
|
||||
|
||||
/// `text` (a run of Thai characters) cut before each character that doesn't attach to the one
|
||||
/// before it: a cluster is a base or leading vowel with the vowels and tone marks above and below
|
||||
/// it, and Sara Am, whose Nikhahit sits on the base. Legacy presentation forms (U+F700–U+F71A)
|
||||
/// are kept with what precedes them.
|
||||
fn thai_clusters(text: &str) -> Vec<&str> {
|
||||
let attaches = |c: char| matches!(u32::from(c), 0x0e31 | 0x0e33..=0x0e3a | 0x0e47..=0x0e4e | 0xf700..=0xf71a);
|
||||
let mut starts: Vec<usize> = text.char_indices().filter(|(at, c)| *at == 0 || !attaches(*c)).map(|(at, _)| at).collect();
|
||||
starts.push(text.len());
|
||||
// Each boundary is a `char_indices` position or the end, strictly increasing: every `get`
|
||||
// succeeds and no cluster is empty.
|
||||
starts.windows(2).filter_map(|w| text.get(*w.first()?..*w.get(1)?)).collect()
|
||||
}
|
||||
|
||||
/// Whether the approved Thai face can replace this source, whose embedded subset cannot
|
||||
/// shape the replacement. A usable complete source program always takes precedence.
|
||||
fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: &str) -> bool {
|
||||
let Some(face) = pdfcraft_fonts::document_thai_font() else { return false };
|
||||
// The bundled face is Regular and upright. A source its descriptor or name marks bold or
|
||||
// italic keeps that style, so completing it with these outlines would be a silent change:
|
||||
// it needs an explicit choice instead. (Variable-font instances often keep the family name.)
|
||||
let style = fonts
|
||||
.get(source.font.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.map_or(FontStyle { bold: source.bold, italic: source.italic }, |f| FontStyle::of(doc, &f, &Metrics::from_dict(doc, &f)));
|
||||
if style.bold || style.italic {
|
||||
return false;
|
||||
}
|
||||
if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
|| !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face))
|
||||
|| !pdfcraft_fonts::embedding::covers(face.bytes, span)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A registered complete matching program also completes the source subset.
|
||||
// Do not replace it with bundled outlines merely because the original resource is a subset.
|
||||
// Only one in the source's style does (`program_candidates`), so only that one counts here.
|
||||
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
|
||||
!fonts.iter().filter_map(|(_, font)| doc.dict(font)).any(|font| {
|
||||
let metrics = Metrics::from_dict(doc, &font);
|
||||
pdfcraft_fonts::embedding::face_key(&metrics.base_font) == family
|
||||
&& FontStyle::of(doc, &font, &metrics) == style
|
||||
&& pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| {
|
||||
pdfcraft_fonts::embedding::validate(&bytes).is_ok()
|
||||
&& pdfcraft_fonts::source_shaping::has_thai_layout(&bytes)
|
||||
&& pdfcraft_fonts::embedding::covers(&bytes, span)
|
||||
&& span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Use the same Thai script selection and mark/cluster positioning as Add Text, including
|
||||
/// rows beginning with Latin or digits (guessing from their first character would select Latin).
|
||||
fn shape_thai_row(text: &str) -> Result<pdfcraft_fonts::source_shaping::ShapedRun, EditError> {
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err(EditError::Invalid("shape one bounded text row at a time".into()));
|
||||
}
|
||||
let clusters =
|
||||
pdfcraft_fonts::shape_thai(text).map_err(|e| EditError::Invalid(format!("the matching Thai font cannot shape this text ({e:?})")))?;
|
||||
let mut glyphs = Vec::new();
|
||||
let mut advance = 0.0;
|
||||
for cluster in clusters {
|
||||
for (i, (id, [x, y])) in cluster.glyphs.into_iter().enumerate() {
|
||||
if glyphs.len() >= 65_534 {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph {
|
||||
id: id.into(),
|
||||
position: [advance + x, y],
|
||||
text: if i == 0 { cluster.text.clone() } else { String::new() },
|
||||
});
|
||||
}
|
||||
advance += cluster.advance;
|
||||
}
|
||||
if !advance.is_finite() {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
Ok(pdfcraft_fonts::source_shaping::ShapedRun { glyphs, advance })
|
||||
}
|
||||
|
||||
/// The resources whose complete program may shape text in the face of `base_font`: `source`
|
||||
/// (the resource `name`) first, then the page's other fonts of that face (at most 128).
|
||||
fn program_candidates(doc: &Document, fonts: &Dict, name: &str, source: Dict, base_font: &str) -> Vec<(String, Dict)> {
|
||||
let key = pdfcraft_fonts::embedding::face_key(base_font);
|
||||
let style = FontStyle::of(doc, &source, &Metrics::from_dict(doc, &source));
|
||||
let mut candidates = vec![(name.to_owned(), source)];
|
||||
candidates.extend(
|
||||
fonts
|
||||
.iter()
|
||||
.filter_map(|(n, f)| Some((String::from_utf8_lossy(n).into_owned(), doc.dict(f)?)))
|
||||
.filter(|(_, f)| {
|
||||
let m = Metrics::from_dict(doc, f);
|
||||
pdfcraft_fonts::embedding::face_key(&m.base_font) == key && FontStyle::of(doc, f, &m) == style
|
||||
})
|
||||
.take(128),
|
||||
);
|
||||
candidates
|
||||
}
|
||||
|
||||
/// A font's weight and slant, for matching subsets and complete programs of one face: a Regular
|
||||
/// program never completes text that a descriptor marks bold, say.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct FontStyle {
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
impl FontStyle {
|
||||
/// Bold from the name or the descriptor's weight ([`Metrics`]); italic from the name or a
|
||||
/// non-zero `/ItalicAngle`. The descriptor's Italic flag alone isn't trusted: exporters set
|
||||
/// it on upright faces (a THSarabunPSK subset with `/Flags 96` and `/ItalicAngle 0`).
|
||||
fn of(doc: &Document, font: &Dict, metrics: &Metrics) -> Self {
|
||||
let descendant =
|
||||
font.get(b"DescendantFonts").and_then(|d| doc.resolve(d).as_array().and_then(|a| a.first()).cloned()).and_then(|d| doc.dict(&d));
|
||||
let descriptor = descendant.as_ref().unwrap_or(font).get(b"FontDescriptor").and_then(|d| doc.dict(d));
|
||||
let angle = descriptor.as_ref().and_then(|d| d.get(b"ItalicAngle")).and_then(|a| doc.resolve(a).as_f64()).unwrap_or(0.0);
|
||||
let name = metrics.base_font.to_ascii_lowercase();
|
||||
Self { bold: metrics.bold, italic: name.contains("italic") || name.contains("oblique") || (angle.is_finite() && angle.abs() > 0.1) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` shaped by `font`'s embedded program with its own layout, when the program covers it and
|
||||
/// may be embedded again (`embed_mapping` refuses one that may not, so a later program is tried).
|
||||
fn shape_with_program(doc: &Document, font: &Dict, text: &str) -> Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun)> {
|
||||
let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, font)?;
|
||||
if pdfcraft_fonts::embedding::validate(&bytes).is_err() || !pdfcraft_fonts::embedding::covers(&bytes, text) {
|
||||
return None;
|
||||
}
|
||||
let shaped = pdfcraft_fonts::source_shaping::shape(&bytes, text).ok()?;
|
||||
Some((bytes, shaped))
|
||||
}
|
||||
|
||||
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
|
||||
fn shape_source(doc: &Document, fonts: &Dict, thai: &ThaiShaping, name: &str, text: &str) -> Result<Option<ShapedText>, EditError> {
|
||||
if thai.outlined.contains(name) {
|
||||
let shaped = shape_thai_row(text)?;
|
||||
return Ok(Some(ShapedText { run: shaped, font: ShapedFont::ThaiOutlines }));
|
||||
}
|
||||
if let Some(resource) = thai.program.get(name) {
|
||||
let font = fonts
|
||||
.get(resource.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.ok_or_else(|| EditError::Invalid("the complete font resource is missing".into()))?;
|
||||
let family = Metrics::from_dict(doc, &font).base_font;
|
||||
let (bytes, shaped) = shape_with_program(doc, &font, text)
|
||||
.ok_or_else(|| EditError::Invalid(format!("{family} cannot shape this text with its own layout; choose a font that covers it")))?;
|
||||
return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } }));
|
||||
}
|
||||
if !pdfcraft_fonts::source_shaping::required(text) {
|
||||
return Ok(None);
|
||||
}
|
||||
let source =
|
||||
fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?;
|
||||
let family = Metrics::from_dict(doc, &source).base_font;
|
||||
let mut candidates = vec![source];
|
||||
candidates.extend(
|
||||
fonts
|
||||
.iter()
|
||||
.filter_map(|(_, f)| doc.dict(f))
|
||||
.filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)),
|
||||
);
|
||||
for font in candidates {
|
||||
if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)
|
||||
&& pdfcraft_fonts::embedding::covers(&bytes, text)
|
||||
&& let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text)
|
||||
{
|
||||
return Ok(Some((bytes, shaped, family)));
|
||||
// In the face's existing codes first; what they can't show, a complete program of the face
|
||||
// may (`legacy_thai_font` already gave the span to one that can).
|
||||
let in_place = (family.rsplit('+').next().unwrap_or(&family).starts_with("THSarabunPSK")
|
||||
&& text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
&& has_thai_source_face(&thai.legacy, &pdfcraft_fonts::embedding::face_key(&family)))
|
||||
.then(|| shape_existing_thai(&thai.legacy, name, text));
|
||||
let in_place = match in_place {
|
||||
Some(Ok(shaped)) => return Ok(Some(shaped)),
|
||||
Some(Err(error)) => Some(error),
|
||||
None => None,
|
||||
};
|
||||
for (_, font) in program_candidates(doc, fonts, name, source, &family) {
|
||||
if let Some((bytes, shaped)) = shape_with_program(doc, &font, text) {
|
||||
return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } }));
|
||||
}
|
||||
}
|
||||
Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into()))
|
||||
if let Some(error) = in_place {
|
||||
return Err(error);
|
||||
}
|
||||
let fallback = pdfcraft_fonts::document_thai_font()
|
||||
.filter(|_| text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)))
|
||||
.map(|face| format!("; or select {} in the document-font menu to replace it", face.family))
|
||||
.unwrap_or_default();
|
||||
Err(EditError::Invalid(format!(
|
||||
"complex text in {} needs its complete source TrueType/OpenType font; use Load font to keep it{fallback}",
|
||||
family.rsplit('+').next().unwrap_or(&family)
|
||||
)))
|
||||
}
|
||||
|
||||
/// For each output row (a range of the draft's characters), whether it is exactly the source row
|
||||
@@ -906,6 +1510,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
max_glyphs,
|
||||
}
|
||||
.prepare()?;
|
||||
let thai = prepared.thai;
|
||||
let chars = prepared.values;
|
||||
let substituted = prepared.substituted;
|
||||
let phrase_size = block.phrase_size();
|
||||
@@ -958,7 +1563,8 @@ pub(crate) fn rewrite_styled_block(
|
||||
}
|
||||
let segment = items.get(at..end).unwrap_or_default();
|
||||
let string: String = segment.iter().map(|v| v.0).collect();
|
||||
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? {
|
||||
if let Some(shaped) = shape_source(doc, &fonts, &thai, &item.2, &string)? {
|
||||
let shaped = &shaped.run;
|
||||
let source = lines.get(item.1).unwrap_or(first);
|
||||
let scale =
|
||||
overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0));
|
||||
@@ -1009,7 +1615,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
let at = if split { start } else { o.bt_op };
|
||||
validate_contextual_rows(&rows, &kept)?;
|
||||
let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::<Result<_, _>>()?;
|
||||
let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?;
|
||||
let shaped = prepare_shaped_rows(doc, &fonts, &thai, &rows, &kept, advance)?;
|
||||
let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped;
|
||||
let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?;
|
||||
let n = pdfcraft_content::num;
|
||||
@@ -1119,67 +1725,19 @@ pub(crate) fn rewrite_styled_block(
|
||||
// drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next.
|
||||
block_ops.push(Op::new("q", vec![]));
|
||||
block_ops.extend(state.paint_ops(drawn_from, run_ctm));
|
||||
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
|
||||
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string)));
|
||||
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
|
||||
state.font = Some((resource.clone(), size_of(source, item.1)));
|
||||
// One text object for the run: each glyph is placed where shaping put it by a TJ
|
||||
// adjustment from where the glyph before it ends (its advance in the embedded
|
||||
// font's widths), and raised by the text rise for a mark set above or below
|
||||
// (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode;
|
||||
// ToUnicode and ActualText keep the logical text.
|
||||
let size = size_of(source, item.1);
|
||||
let per_unit = 1000.0 / size;
|
||||
if !per_unit.is_finite() {
|
||||
return Err(EditError::Invalid("the shaped text has no size".into()));
|
||||
}
|
||||
let shown = fonts
|
||||
.get(resource.as_bytes())
|
||||
.and_then(|f| doc.resolve(f).as_dict().cloned())
|
||||
.map(|d| Metrics::from_dict(doc, &d))
|
||||
.ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
// Spacing is part of each glyph's position, so the run itself has none.
|
||||
let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise);
|
||||
state.char_spacing = 0.0;
|
||||
state.word_spacing = 0.0;
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.extend(state.ops());
|
||||
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
|
||||
// `pen`: where the next glyph would start, in thousandths of the font size.
|
||||
let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise);
|
||||
let mut shown_glyphs: Vec<Object> = Vec::new();
|
||||
for (i, glyph) in shaped.glyphs.iter().enumerate() {
|
||||
let target = (glyph.position[0] * size + extra) * per_unit;
|
||||
let glyph_rise = rise + glyph.position[1] * size;
|
||||
if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 {
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))]));
|
||||
}
|
||||
if (glyph_rise - raised).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(glyph_rise)]));
|
||||
raised = glyph_rise;
|
||||
}
|
||||
}
|
||||
if (target - pen).abs() > 1e-6 {
|
||||
shown_glyphs.push(n(pen - target));
|
||||
}
|
||||
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
|
||||
shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec())));
|
||||
pen = target + shown.width(u32::from(code)) * 1000.0;
|
||||
extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)]));
|
||||
}
|
||||
if (raised - rise).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(rise)]));
|
||||
}
|
||||
block_ops.push(Op::new("ET", vec![]));
|
||||
(state.char_spacing, state.word_spacing) = (char_spacing, word_spacing);
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k;
|
||||
if let Some(shaped) = shaped_runs.get(&(row_index, pos)) {
|
||||
let resources = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let drawing = ShapedRunDrawing {
|
||||
doc,
|
||||
fonts: &fonts,
|
||||
shaped: &shaped.run,
|
||||
resources,
|
||||
text: &string,
|
||||
matrix: tm,
|
||||
size: size_of(source, item.1),
|
||||
thai: string.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)),
|
||||
};
|
||||
segment_width = drawing.draw(&mut block_ops, &mut state)? * state.scale * source.origin.k;
|
||||
} else {
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.extend(state.ops());
|
||||
|
||||
+232
-79
@@ -13,27 +13,32 @@
|
||||
//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft
|
||||
//! mask, or a group with a blending colour space (`/CS`) other than the page's: its contents
|
||||
//! composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they would
|
||||
//! composite in the page's. Those are refused rather than changed, as is a form whose
|
||||
//! `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` the page doesn't share (it remaps the form's
|
||||
//! device colours, §8.6.5.6, and inline the page's resources apply), tagged form content (its
|
||||
//! structure link would be lost) and content whose marked content or text objects don't balance
|
||||
//! (inlined, they would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
|
||||
//! composite in the page's. Those are refused rather than changed, as is a form with resources
|
||||
//! of its own whose `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` differs from the page's, one
|
||||
//! having it and the other not included (it remaps the device colours painted with it,
|
||||
//! §8.6.5.6, and inline the page's resources apply), tagged form content (its structure link
|
||||
//! would be lost) and content whose marked content or text objects don't balance (inlined, they
|
||||
//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
|
||||
//!
|
||||
//! A pattern's matrix maps pattern space to the default space of the content stream that uses it
|
||||
//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is
|
||||
//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose
|
||||
//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused.
|
||||
//!
|
||||
//! Edit text's preparation of grouped text (`text::forms`) opens each placement with this same
|
||||
//! code, and decides what to open from what [`Refusal`] and [`Ungrouped::shows_text`] say.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, parse};
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above
|
||||
/// any real form, and a bomb must not be copied into the page.
|
||||
const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
/// any real form, and a bomb must not be copied into the page. Ungroup reads the form and what it
|
||||
/// draws within one [`DecodeBudget`] of this size.
|
||||
pub(crate) const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
@@ -45,28 +50,81 @@ const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK
|
||||
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
|
||||
const MAX_MOVED_PATTERNS: usize = 256;
|
||||
|
||||
/// The most entries of one resource category merged: a form or page with more is refused.
|
||||
const MAX_RESOURCES: usize = 4096;
|
||||
|
||||
/// The deepest `q` nesting a form's content may have.
|
||||
const MAX_SAVES: usize = 4096;
|
||||
|
||||
/// A name map for one resource category: form name → name in the page's resources.
|
||||
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
|
||||
|
||||
/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with
|
||||
/// the form's merged in, and the pattern copies to add to the document, each under the name in
|
||||
/// the merged `/Pattern` dictionary that the ops already use.
|
||||
pub(crate) type Ungrouped = (Vec<Op>, Dict, Vec<(Vec<u8>, Object)>);
|
||||
/// What [`ungroup_ops`] gives back.
|
||||
pub(crate) struct Ungrouped {
|
||||
/// The ops that draw the form inline.
|
||||
pub(crate) ops: Vec<Op>,
|
||||
/// The page resources with the form's merged in.
|
||||
pub(crate) resources: Dict,
|
||||
/// The pattern copies to add to the document, each under the name in the merged `/Pattern`
|
||||
/// dictionary that the ops already use.
|
||||
pub(crate) patterns: Vec<(Vec<u8>, Object)>,
|
||||
/// Whether the form shows text, in its own content or in a Form XObject it draws (at any
|
||||
/// depth [`MAX_NESTING`] allows). Text inside a tiling pattern or Type 3 glyph doesn't count:
|
||||
/// inlining the form doesn't make it editable. `None` when something it draws can't be read,
|
||||
/// is nested too deep, draws itself, or is past the reads or the decode budget allowed.
|
||||
pub(crate) shows_text: Option<bool>,
|
||||
}
|
||||
|
||||
/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state
|
||||
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
|
||||
/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]).
|
||||
/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`,
|
||||
/// then the CTM there).
|
||||
/// Why [`ungroup_ops`] left a placement grouped.
|
||||
pub(crate) struct Refusal {
|
||||
pub(crate) error: EditError,
|
||||
/// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every
|
||||
/// placement of it is refused the same way. Otherwise something about this placement or the
|
||||
/// page so far is: its translucency or matrix, the resources it would merge into (which grow
|
||||
/// as other groups are prepared), or the decode budget left.
|
||||
pub(crate) intrinsic: bool,
|
||||
/// The form's resources need renaming where it is drawn, and it draws something that would
|
||||
/// look them up by their old names. Names merged in for other forms can make the renaming
|
||||
/// unnecessary, which [`merges_without_renaming`] checks without reading the content.
|
||||
pub(crate) renaming: bool,
|
||||
}
|
||||
|
||||
/// Ungroup the form drawn by the `Do` at `page_ops[at]` (the page's streams parsed as one, the
|
||||
/// indexes `page_images` gives) from the page's `resources`. `page_space` is the page group's
|
||||
/// blending colour space ([`page_blend_space`]). `placement` maps the form's space to the page's
|
||||
/// default space at the `Do` (its `/Matrix`, then the CTM there). The form, and the forms and
|
||||
/// patterns it draws, decode within `budget`.
|
||||
pub(crate) fn ungroup_ops(
|
||||
doc: &Document,
|
||||
resources: &Dict,
|
||||
name: &[u8],
|
||||
translucent: bool,
|
||||
page_ops: &[Op],
|
||||
at: usize,
|
||||
page_space: Option<&Object>,
|
||||
placement: Matrix,
|
||||
) -> Result<Ungrouped, EditError> {
|
||||
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Result<Ungrouped, Refusal> {
|
||||
let refusal = |why: &str, intrinsic: bool| Refusal {
|
||||
error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")),
|
||||
intrinsic,
|
||||
renaming: false,
|
||||
};
|
||||
// Something about this placement or the page so far is the reason.
|
||||
let refuse = |why: &str| refusal(why, false);
|
||||
// The form itself is the reason: every placement of it is refused the same way.
|
||||
let invalid = |why: &str| refusal(why, true);
|
||||
let op = page_ops.get(at).filter(|op| op.is("Do")).ok_or_else(|| refuse("it is missing"))?;
|
||||
// The renderer draws a `Do`'s last operand, and the placement was read for its first
|
||||
// (`page_images`): with more than one they can be different forms.
|
||||
if op.operands.len() != 1 {
|
||||
return Err(refuse("it is drawn with an operand too many"));
|
||||
}
|
||||
let name = op.name(0).ok_or_else(|| refuse("it is missing"))?;
|
||||
let (translucent, open) = state_at(doc, resources, page_ops, at);
|
||||
// Inline, its clip would take in the page's unfinished path and its text objects would be
|
||||
// nested in the page's.
|
||||
if open {
|
||||
return Err(refuse("it is drawn inside an unfinished path or text object"));
|
||||
}
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
|
||||
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
|
||||
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
|
||||
@@ -75,29 +133,35 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
|
||||
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
|
||||
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
let a = a.as_array()?;
|
||||
// Every entry must be a number: a short array padded out by a non-number is unreadable.
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(a.len() == n && v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
};
|
||||
let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?;
|
||||
let bbox = numbers(b"BBox", 4).and_then(|b| <[f64; 4]>::try_from(b).ok()).ok_or_else(|| invalid("its bounding box is missing or unreadable"))?;
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
None => None,
|
||||
Some(_) => Some(Matrix(
|
||||
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?)
|
||||
.map_err(|_| refuse("its matrix is unreadable"))?,
|
||||
)),
|
||||
Some(_) => Some(Matrix(numbers(b"Matrix", 6).and_then(|m| <[f64; 6]>::try_from(m).ok()).ok_or_else(|| invalid("its matrix is unreadable"))?)),
|
||||
};
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
if s.dict.get(b"Resources").is_some() && own.is_none() {
|
||||
return Err(invalid("its resources can't be read"));
|
||||
}
|
||||
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
|
||||
let form_res = own.clone().unwrap_or_else(|| resources.clone());
|
||||
|
||||
// A tagged form's content is a structure element's: drawn inline it would lose that link.
|
||||
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
|
||||
return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
return Err(invalid("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
}
|
||||
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"));
|
||||
let group_dict = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned());
|
||||
if s.dict.get(b"Group").is_some() && group_dict.is_none() {
|
||||
return Err(invalid("its group dictionary can't be read"));
|
||||
}
|
||||
let group = group_dict.filter(|g| is_name(doc, g, b"S", b"Transparency"));
|
||||
if let Some(group) = &group {
|
||||
if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) {
|
||||
return Err(refuse("it is a knockout transparency group"));
|
||||
return Err(invalid("it is a knockout transparency group"));
|
||||
}
|
||||
if translucent {
|
||||
return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask"));
|
||||
@@ -107,32 +171,39 @@ pub(crate) fn ungroup_ops(
|
||||
if let Some(cs) = group.get(b"CS")
|
||||
&& !same_blend_space(doc, cs, page_space)
|
||||
{
|
||||
return Err(refuse("it is a transparency group that blends in a colour space of its own"));
|
||||
return Err(invalid("it is a transparency group that blends in a colour space of its own"));
|
||||
}
|
||||
}
|
||||
|
||||
// Strictly: damaged data must not be copied in as a shorter form.
|
||||
let data = s.decoded_strict_within(MAX_FORM_BYTES).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let data = budget.decode_strict(doc, s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let parsed = parse(&data);
|
||||
if parsed.skipped > 0 {
|
||||
return Err(refuse("its content has parts that can't be read"));
|
||||
return Err(invalid("its content has parts that can't be read"));
|
||||
}
|
||||
let mut ops = parsed.ops;
|
||||
if group.is_some() {
|
||||
// The form itself is being read: drawing itself again counts as blending.
|
||||
let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0 };
|
||||
if blends_inside(doc, &form_res, &ops, 0, &mut walk) {
|
||||
return Err(refuse("it is a transparency group with blend modes inside"));
|
||||
}
|
||||
// What it draws is read for text whether it is a group or not, and for blend modes only in a
|
||||
// group: without one, blending inside draws the same inline. The form itself is being read,
|
||||
// so drawing itself again is content that can't be checked.
|
||||
let mut walk =
|
||||
BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget, text: false, group: group.is_some() };
|
||||
let unchecked = blends_inside(doc, &form_res, &ops, 0, &mut walk);
|
||||
if unchecked && group.is_some() {
|
||||
return Err(refuse("it is a transparency group with blend modes inside, or content inside that can't be checked"));
|
||||
}
|
||||
let shows_text = (!unchecked).then_some(walk.text);
|
||||
|
||||
// A form's default colour space remaps its device colours (ISO 32000-2 §8.6.5.6); inline,
|
||||
// the page's resources are the current ones (§7.8.3).
|
||||
if own.is_some()
|
||||
&& let Some(key) = unshared_default_space(doc, &form_res, resources)
|
||||
&& let Some((key, form_has)) = unshared_default_space(doc, &form_res, resources)
|
||||
{
|
||||
let key = String::from_utf8_lossy(key);
|
||||
return Err(refuse(&format!("it remaps its device colours with a /{key} the page doesn't share")));
|
||||
return Err(refuse(&if form_has {
|
||||
format!("it remaps its device colours with a /{key} the page doesn't share")
|
||||
} else {
|
||||
format!("the page's /{key} would remap its device colours, which it draws as they are")
|
||||
}));
|
||||
}
|
||||
|
||||
// Merge the form's resources into a copy of the page's, renaming what clashes.
|
||||
@@ -141,7 +212,13 @@ pub(crate) fn ungroup_ops(
|
||||
if own.is_some() {
|
||||
for cat in CATEGORIES {
|
||||
let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
|
||||
if theirs.len() > MAX_RESOURCES {
|
||||
return Err(invalid("too many resources"));
|
||||
}
|
||||
let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
if mine.len() > MAX_RESOURCES {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
let mut map = Renames::new();
|
||||
let mut n = 0u32;
|
||||
for (key, value) in theirs.iter() {
|
||||
@@ -163,6 +240,11 @@ pub(crate) fn ungroup_ops(
|
||||
mine.set(fresh.clone(), value.clone());
|
||||
map.push((key.to_vec(), fresh));
|
||||
}
|
||||
// Counted after the merge: a page taken past the limit would refuse every later form,
|
||||
// even one whose resources are all the page's already.
|
||||
if mine.len() > MAX_RESOURCES {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
merged.set(cat.to_vec(), Object::Dict(mine));
|
||||
if !map.is_empty() {
|
||||
renames.push((cat, map));
|
||||
@@ -177,7 +259,7 @@ pub(crate) fn ungroup_ops(
|
||||
// would find the page's, which differ wherever a name was renamed (every form resource that
|
||||
// isn't already the page's, the nested item's own entry included).
|
||||
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) {
|
||||
return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources"));
|
||||
return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") });
|
||||
}
|
||||
let mut depth = 0usize;
|
||||
// Marked content and text objects must balance within the form's own stream: an extra EMC
|
||||
@@ -187,19 +269,20 @@ pub(crate) fn ungroup_ops(
|
||||
for op in &mut ops {
|
||||
match op.op.as_slice() {
|
||||
b"BDC" | b"BMC" => marked = marked.saturating_add(1),
|
||||
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?,
|
||||
b"BT" if in_text => return Err(refuse("its content begins a text object inside another")),
|
||||
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?,
|
||||
b"BT" if in_text => return Err(invalid("its content begins a text object inside another")),
|
||||
b"BT" => in_text = true,
|
||||
b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")),
|
||||
b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")),
|
||||
b"ET" => in_text = false,
|
||||
_ => {}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" => depth = depth.saturating_add(1),
|
||||
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?,
|
||||
b"q" if depth >= MAX_SAVES => return Err(invalid("its graphics states are nested too deeply")),
|
||||
b"q" => depth += 1,
|
||||
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?,
|
||||
_ => {}
|
||||
}
|
||||
let slot = resource_operand(op).map_err(|Unreadable| refuse("its content selects a resource with operands that can't be read"))?;
|
||||
let slot = resource_operand(op).map_err(|Unreadable| invalid("its content selects a resource with operands that can't be read"))?;
|
||||
if let Some((cat, i)) = slot
|
||||
&& let Some(operand) = op.operands.get_mut(i)
|
||||
&& let Object::Name(n) = operand
|
||||
@@ -219,13 +302,15 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
|
||||
if marked > 0 || in_text {
|
||||
return Err(refuse("its content leaves marked content or a text object open"));
|
||||
return Err(invalid("its content leaves marked content or a text object open"));
|
||||
}
|
||||
|
||||
// Patterns keep their place relative to the shapes they fill: each one the form selects is
|
||||
// drawn from a copy that also carries the placement. Nested forms and patterns inside
|
||||
// patterns keep their own parents, so only this stream's selections change.
|
||||
let mut patterns: Vec<(Vec<u8>, Object)> = Vec::new();
|
||||
// What the copies of tiling patterns may add in all.
|
||||
let mut pattern_bytes = MAX_FORM_BYTES;
|
||||
if placement != Matrix::IDENTITY {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut moved: Renames = Vec::new();
|
||||
@@ -243,6 +328,9 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
let copy =
|
||||
moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?;
|
||||
if let Object::Stream(s) = © {
|
||||
pattern_bytes = pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies would be too large"))?;
|
||||
}
|
||||
let fresh = loop {
|
||||
n = n.saturating_add(1);
|
||||
let candidate = format!("PCUgPat{n}").into_bytes();
|
||||
@@ -300,28 +388,59 @@ pub(crate) fn ungroup_ops(
|
||||
if let Some(m) = matrix {
|
||||
out.push(Op::new("cm", m.0.iter().copied().map(num).collect()));
|
||||
}
|
||||
let (x0, y0, x1, y1) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3]));
|
||||
let [a, b, c, d] = bbox;
|
||||
let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d));
|
||||
out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)]));
|
||||
out.push(Op::new("W", Vec::new()));
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
out.extend(ops);
|
||||
// A path the form leaves unfinished ends with it, not in the page's content after it.
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
// A form ends with the state it was drawn with (the Do saves and restores it).
|
||||
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
|
||||
out.push(Op::new("Q", Vec::new()));
|
||||
if layer.is_some() {
|
||||
out.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
Ok((out, merged, patterns))
|
||||
Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text })
|
||||
}
|
||||
|
||||
/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` in the form's `/ColorSpace`
|
||||
/// resources that the page's don't have with an equal value (the same object, or the same value
|
||||
/// resolved). A default colour space remaps the device colours painted with the resource
|
||||
/// dictionary it is in (ISO 32000-2 §8.6.5.6, §7.8.3), so inline only the page's would apply.
|
||||
fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<&'static [u8]> {
|
||||
/// Whether form `name` in `resources` would merge into them without a rename: it has resources
|
||||
/// of its own, and each of their entries is already in `resources` under the same name, as
|
||||
/// [`ungroup_ops`] compares them. Only dictionaries are read, never the form's content.
|
||||
pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[u8]) -> bool {
|
||||
let category = |d: &Dict, cat: &[u8]| d.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let Some(form) = category(resources, b"XObject").and_then(|x| x.get(name).cloned()) else { return false };
|
||||
let Object::Stream(s) = &*doc.resolve(&form) else { return false };
|
||||
let Some(own) = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else { return false };
|
||||
CATEGORIES.into_iter().all(|cat| {
|
||||
let Some(theirs) = category(&own, cat) else { return true };
|
||||
let mine = category(resources, cat).unwrap_or_default();
|
||||
// Past these sizes `ungroup_ops` refuses the form anyway. Default colour spaces are
|
||||
// never renamed (`ungroup_ops` checks them separately).
|
||||
theirs.len() <= MAX_RESOURCES
|
||||
&& mine.len() <= MAX_RESOURCES
|
||||
&& theirs.iter().all(|(key, value)| mine.get(key) == Some(value) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&&key[..])))
|
||||
})
|
||||
}
|
||||
|
||||
/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` that the form's and the page's
|
||||
/// `/ColorSpace` resources don't share with an equal value (the same object, or the same value
|
||||
/// resolved), and whether the form has it (else only the page does). A default colour space
|
||||
/// remaps the device colours painted with the resource dictionary it is in (ISO 32000-2
|
||||
/// §8.6.5.6, §7.8.3), so inline only the page's would apply: the form's would no longer remap
|
||||
/// its colours, and the page's would start to.
|
||||
fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<(&'static [u8], bool)> {
|
||||
let spaces = |res: &Dict| res.get(b"ColorSpace").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (theirs, mine) = (spaces(form), spaces(page));
|
||||
DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some_and(|v| !mine.get(key).is_some_and(|p| *doc.resolve(p) == *doc.resolve(v))))
|
||||
let differs = |key: &[u8]| match (theirs.get(key), mine.get(key)) {
|
||||
(None, None) => false,
|
||||
(Some(v), Some(p)) => *doc.resolve(p) != *doc.resolve(v),
|
||||
_ => true,
|
||||
};
|
||||
// The form's own entries are named first.
|
||||
let form_has = DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some() && differs(key)).map(|key| (key, true));
|
||||
form_has.or_else(|| DEFAULT_SPACES.into_iter().find(|key| differs(key)).map(|key| (key, false)))
|
||||
}
|
||||
|
||||
/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead
|
||||
@@ -339,6 +458,10 @@ fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option<Ob
|
||||
Some(m) => {
|
||||
let m = doc.resolve(m);
|
||||
let nums: Vec<Object> = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect();
|
||||
// A matrix is six numbers: a longer array is unreadable, not cut short.
|
||||
if nums.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
Matrix::from_operands(&nums)?
|
||||
}
|
||||
};
|
||||
@@ -362,12 +485,23 @@ const MAX_NESTING: usize = 8;
|
||||
const MAX_NESTED_READS: usize = 1024;
|
||||
|
||||
/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a
|
||||
/// form drawing itself), the answer for each form with resources of its own (its answer doesn't
|
||||
/// depend on who draws it), and how many reads it has made.
|
||||
struct BlendWalk {
|
||||
/// form drawing itself), the answers for each form with resources of its own (whether it blends
|
||||
/// or can't be checked, and whether it shows text; neither depends on who draws it), how many
|
||||
/// reads it has made, the decode budget they share with the form itself, whether the content
|
||||
/// being walked shows text itself or through a Form XObject it draws, and whether blend modes
|
||||
/// count (they do in a group).
|
||||
struct BlendWalk<'a> {
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
known: HashMap<pdfcraft_cos::ObjRef, bool>,
|
||||
known: HashMap<pdfcraft_cos::ObjRef, (bool, bool)>,
|
||||
reads: usize,
|
||||
budget: &'a mut DecodeBudget,
|
||||
text: bool,
|
||||
group: bool,
|
||||
}
|
||||
|
||||
/// Whether `op` shows text (ISO 32000-2 §9.4.3, Table 107).
|
||||
fn shows_text(op: &Op) -> bool {
|
||||
matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"")
|
||||
}
|
||||
|
||||
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
|
||||
@@ -440,15 +574,22 @@ fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
|
||||
drawn && dict.get(b"Resources").is_none()
|
||||
}
|
||||
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they
|
||||
/// use, or one used by a form or pattern they draw, at any depth. A shading pattern blends through
|
||||
/// its own `/ExtGState`, a tiling pattern through its content. A nested form or pattern that can't
|
||||
/// be read, is nested too deep or draws itself counts as blending, so the caller refuses rather
|
||||
/// than guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk) -> bool {
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts
|
||||
/// only in a group (`walk.group`): a `gs` they use, or one used by a form or pattern they draw, at
|
||||
/// any depth. A shading pattern blends through its own `/ExtGState`, a tiling pattern through its
|
||||
/// content. A nested form or pattern that can't be read, is nested too deep, draws itself or is
|
||||
/// past the reads or the budget allowed counts as blending, so the caller refuses rather than
|
||||
/// guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped. Sets
|
||||
/// `walk.text` when `ops` show text, or a Form XObject they draw does; text in a tiling pattern's
|
||||
/// cell is not the page's text and is not counted.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk<'_>) -> bool {
|
||||
let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern"));
|
||||
let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
|
||||
let group = walk.group;
|
||||
let state_blends = |g: Option<&Object>| group && g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
|
||||
if ops.iter().any(shows_text) {
|
||||
walk.text = true;
|
||||
}
|
||||
for op in ops {
|
||||
let Ok(Some((cat, i))) = resource_operand(op) else { continue };
|
||||
let Some(name) = op.name(i) else { continue };
|
||||
@@ -479,17 +620,19 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal
|
||||
_ => continue,
|
||||
};
|
||||
let r = o.as_ref();
|
||||
if let Some(known) = r.and_then(|r| walk.known.get(&r)) {
|
||||
if *known {
|
||||
let drawn_form = op.is("Do");
|
||||
if let Some((known, text)) = r.and_then(|r| walk.known.get(&r)).copied() {
|
||||
if known {
|
||||
return true;
|
||||
}
|
||||
walk.text |= drawn_form && text;
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS {
|
||||
return true;
|
||||
}
|
||||
walk.reads += 1;
|
||||
let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true };
|
||||
let Ok(data) = walk.budget.decode_strict(doc, nested) else { return true };
|
||||
let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
let has_own = own_resources.is_some();
|
||||
let own = own_resources.unwrap_or_else(|| resources.clone());
|
||||
@@ -500,11 +643,14 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal
|
||||
if let Some(r) = r {
|
||||
walk.path.push(r);
|
||||
}
|
||||
let outer_text = std::mem::replace(&mut walk.text, false);
|
||||
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk);
|
||||
let inner_text = walk.text;
|
||||
walk.text = outer_text || (drawn_form && inner_text);
|
||||
if let Some(r) = r {
|
||||
walk.path.retain(|x| *x != r);
|
||||
if has_own {
|
||||
walk.known.insert(r, blends);
|
||||
walk.known.insert(r, (blends, inner_text));
|
||||
}
|
||||
}
|
||||
if blends {
|
||||
@@ -552,18 +698,25 @@ fn blends(doc: &Document, gs: &Dict) -> bool {
|
||||
!first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible")))
|
||||
}
|
||||
|
||||
/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's
|
||||
/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs`
|
||||
/// changes only the parameters it sets.
|
||||
pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool {
|
||||
/// The state at op `at` of the page's streams parsed as one: whether the graphics state is
|
||||
/// translucent, blended or soft-masked there, following `q`/`Q` and `gs` (each `gs` changes only
|
||||
/// the parameters it sets), which a transparency group composites as a whole; and whether a path
|
||||
/// or a text object is open there.
|
||||
fn state_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> (bool, bool) {
|
||||
let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
// Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal.
|
||||
let mut stack: Vec<[bool; 4]> = Vec::new();
|
||||
let mut now = [false; 4];
|
||||
let (mut path, mut text) = (false, false);
|
||||
for op in ops.iter().take(at) {
|
||||
match op.op.as_slice() {
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" | b"W" | b"W*" => path = true,
|
||||
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => path = false,
|
||||
b"BT" => text = true,
|
||||
b"ET" => text = false,
|
||||
b"q" => stack.push(now),
|
||||
b"Q" => now = stack.pop().unwrap_or([false; 4]),
|
||||
// A Q with nothing to restore is ignored, as renderers ignore it.
|
||||
b"Q" => now = stack.pop().unwrap_or(now),
|
||||
b"gs" => {
|
||||
let Ok(Some((_, i))) = resource_operand(op) else { continue };
|
||||
let Some(gs) = op.name(i).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else {
|
||||
@@ -586,5 +739,5 @@ pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: u
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
now.iter().any(|v| *v)
|
||||
(now.iter().any(|v| *v), path || text)
|
||||
}
|
||||
|
||||
@@ -260,6 +260,15 @@ impl Document {
|
||||
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
|
||||
}
|
||||
|
||||
/// Edit text: whether grouped artwork `index` on `page` (0-based, as [`Self::page_images`]
|
||||
/// lists them) shows text that [`Edit::PrepareText`](crate::Edit::PrepareText) would make
|
||||
/// editable. Reads the page without changing it; `false` for a logo, a group that can't be
|
||||
/// opened or a page that can't be read.
|
||||
pub fn grouped_text_at(&self, page: usize, index: usize) -> bool {
|
||||
let Some(editor) = self.editor.as_ref() else { return false };
|
||||
crate::guard(|| pdfcraft_edit::text::forms::grouped_text_at(&editor.cos, page, index)).is_ok_and(|r| r.is_ok_and(|yes| yes))
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based).
|
||||
pub fn text_blocks(&self, page: usize) -> Vec<pdfcraft_edit::TextBlock> {
|
||||
self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default()
|
||||
|
||||
@@ -466,6 +466,10 @@ pub enum Edit {
|
||||
/// Formatting changes (font, size, colour, alignment); default keeps the paragraph's.
|
||||
style: pdfcraft_edit::BlockStyle,
|
||||
},
|
||||
/// Expose supported grouped source text on one page for direct editing.
|
||||
PrepareText {
|
||||
page: usize,
|
||||
},
|
||||
/// An explicit open-licensed full font program, completing subsets or selected by a user.
|
||||
RegisterTextFont {
|
||||
page: usize,
|
||||
|
||||
@@ -111,6 +111,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Delete document JavaScript",
|
||||
"Edit document JavaScript",
|
||||
"Edit text",
|
||||
"Prepare grouped text",
|
||||
"Load document font",
|
||||
"Move object",
|
||||
"Move objects",
|
||||
@@ -269,6 +270,7 @@ impl Edit {
|
||||
Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"),
|
||||
Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"),
|
||||
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"),
|
||||
Edit::PrepareText { .. } => Label::of("Prepare grouped text"),
|
||||
Edit::RegisterTextFont { .. } => Label::of("Load document font"),
|
||||
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
|
||||
Edit::EditPageImage { change, .. } => Label::of(match change {
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::RemoveStandardId { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::PrepareText { .. }
|
||||
| Edit::RegisterTextFont { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
| Edit::Flatten { .. }
|
||||
@@ -193,6 +194,9 @@ pub(crate) struct EditCtx {
|
||||
/// The overlay streams the edit's applied redactions added, which alone the renderer's check
|
||||
/// leaves out.
|
||||
pub(crate) redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
|
||||
/// What [`Edit::PrepareText`] prepared, repaired and left grouped, for
|
||||
/// [`Session::prepare_text`](crate::Session::prepare_text).
|
||||
pub(crate) prepared: Option<pdfcraft_edit::text::forms::Preparation>,
|
||||
}
|
||||
|
||||
impl EditCtx {
|
||||
@@ -222,6 +226,7 @@ impl EditCtx {
|
||||
rng: getrandom::fill,
|
||||
renamed_fields: Vec::new(),
|
||||
redaction_overlays: Vec::new(),
|
||||
prepared: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,6 +732,15 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::EditTextBlock { page, block, text, style } => {
|
||||
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
|
||||
}
|
||||
Edit::PrepareText { page } => {
|
||||
let result = pdfcraft_edit::text::forms::prepare(doc, *page)?;
|
||||
if result.expanded == 0 && result.repaired_fonts == 0 {
|
||||
return Err(EditError::Invalid(
|
||||
result.refused.first().cloned().unwrap_or_else(|| "this page has no supported grouped text to prepare".into()),
|
||||
));
|
||||
}
|
||||
cx.prepared = Some(result);
|
||||
}
|
||||
Edit::RegisterTextFont { page, bytes } => {
|
||||
pdfcraft_edit::text::register_text_font(doc, *page, bytes)?;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
Edit::MoveObjects { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::PrepareText { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
| Edit::EnhanceScan { .. }
|
||||
| Edit::AddText { .. }
|
||||
|
||||
@@ -20,6 +20,19 @@ fn deletes_pages(edit: &Edit) -> bool {
|
||||
impl Session {
|
||||
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
|
||||
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
|
||||
self.apply_reporting(id, edit).map(|_| ())
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: make the grouped text on `page` (0-based) editable, as one undoable
|
||||
/// [`Edit::PrepareText`], and say what it prepared, which fonts' maps it repaired and which
|
||||
/// groups it left as they were (and why). The counts are this edit's own, not a preview's.
|
||||
pub fn prepare_text(&mut self, id: DocId, page: usize) -> Result<pdfcraft_edit::text::forms::Preparation, EditError> {
|
||||
self.apply_reporting(id, Edit::PrepareText { page })?
|
||||
.ok_or_else(|| EditError::Invalid("preparing grouped text gave no result; the document was not changed".into()))
|
||||
}
|
||||
|
||||
/// [`Session::apply`], giving back what an [`Edit::PrepareText`] did (`None` for other edits).
|
||||
fn apply_reporting(&mut self, id: DocId, edit: Edit) -> Result<Option<pdfcraft_edit::text::forms::Preparation>, EditError> {
|
||||
let now = self.now();
|
||||
let today = self.today();
|
||||
let js_off = self.js_off;
|
||||
@@ -61,7 +74,7 @@ impl Session {
|
||||
};
|
||||
// Nothing to bake: leave undo history and the dirty flag alone.
|
||||
if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let mut next = editor.cos.clone();
|
||||
if !js_off && uses_scripts(&edit) {
|
||||
@@ -228,7 +241,7 @@ impl Session {
|
||||
doc.xfa_scripts_off = true;
|
||||
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
Ok(())
|
||||
Ok(cx.prepared)
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
|
||||
@@ -86,3 +86,11 @@ Document font inputs use `embedding`: bounded static TrueType/OpenType-CFF valid
|
||||
exact program preservation, Unicode/CID/GID mapping and full-face subset completion.
|
||||
`shaping` uses the same program with harfrust and UAX #9; source UI fallback fonts are
|
||||
never exported. See [existing-text editing](../../docs/editing-existing-text.md).
|
||||
|
||||
`pdf::embedded_sfnt_within` reconstructs a missing Unicode cmap for Identity-H TrueType
|
||||
subsets from the PDF's ToUnicode and CID-to-glyph maps, and completes a THSarabunPSK subset's
|
||||
Latin-only one with them. Only the in-memory editor copy changes;
|
||||
the PDF's font program stays intact. `pdf::LegacyThaiFont` shapes verified THSarabunPSK
|
||||
positional glyphs into their original PDF codes, retaining the source face and weight. Missing
|
||||
contextual glyphs are refused. `pdf::recovered_thai_map` recognizes narrowly fingerprinted
|
||||
damaged exporter maps; callers must record repairs and apply them to page-owned font copies.
|
||||
|
||||
+105
-19
@@ -108,30 +108,49 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue };
|
||||
let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 {
|
||||
continue;
|
||||
}
|
||||
if face.charmap().mappings().next().is_some() {
|
||||
let own_cmap = face.charmap().mappings().next().is_some();
|
||||
// A legacy THSarabunPSK subset may keep a Latin-only cmap beside its Thai glyphs, which
|
||||
// only the PDF's verified maps reach (`composite_glyph_maps`); other programs keep theirs.
|
||||
let base_font = font.name(b"BaseFont").map(String::from_utf8_lossy).unwrap_or_default();
|
||||
if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) {
|
||||
return Some(bytes);
|
||||
}
|
||||
// Some PDF generators keep a Mac format-6 code→glyph cmap. egui accepts Unicode
|
||||
// format-4/12 cmaps. Combine the PDF's Unicode meanings with the source glyph IDs;
|
||||
// only this in-memory copy gets a new cmap, never the working PDF or a font asset.
|
||||
let Ok(cmap) = face.cmap() else { continue };
|
||||
// PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only
|
||||
// cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged.
|
||||
let metrics = Metrics::from_dict(doc, font);
|
||||
if metrics.composite {
|
||||
continue;
|
||||
}
|
||||
let mut mappings = std::collections::BTreeMap::new();
|
||||
for code in 0..=255 {
|
||||
let Some(text) = metrics.unicode.text(code) else { continue };
|
||||
let Some(ch) = only_char(&text) else { continue };
|
||||
let Some(glyph) = cmap.map_codepoint(code).or_else(|| cmap.map_codepoint(code + 0xf000)) else { continue };
|
||||
let glyph = glyph.to_u32();
|
||||
if glyph > 0 {
|
||||
mappings.entry(u32::from(ch)).or_insert(glyph);
|
||||
if metrics.composite {
|
||||
if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) {
|
||||
mappings = map;
|
||||
}
|
||||
if own_cmap {
|
||||
let charmap = face.charmap();
|
||||
if mappings.keys().all(|&u| char::from_u32(u).is_some_and(|c| charmap.map(c).is_some())) {
|
||||
return Some(bytes);
|
||||
}
|
||||
// The program's own mappings stay; the PDF's fill only what they lack.
|
||||
mappings.extend(charmap.mappings().filter(|(_, g)| g.to_u32() != 0).take(65_537).map(|(u, g)| (u, g.to_u32())));
|
||||
let mut merged = bytes.clone();
|
||||
let usable = append_editor_cmap(&mut merged, &mappings).is_some()
|
||||
&& skrifa::FontRef::new(&merged).is_ok_and(|font| font.charmap().mappings().next().is_some());
|
||||
return Some(if usable { merged } else { bytes });
|
||||
}
|
||||
} else if own_cmap {
|
||||
return Some(bytes);
|
||||
} else if let Ok(cmap) = face.cmap() {
|
||||
for code in 0..=255 {
|
||||
let Some(text) = metrics.unicode.text(code) else { continue };
|
||||
let Some(ch) = only_char(&text) else { continue };
|
||||
let Some(glyph) = cmap.map_codepoint(code).or_else(|| cmap.map_codepoint(code + 0xf000)) else { continue };
|
||||
let glyph = glyph.to_u32();
|
||||
if glyph > 0 {
|
||||
mappings.entry(u32::from(ch)).or_insert(glyph);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !mappings.is_empty()
|
||||
@@ -214,14 +233,14 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
|
||||
/// Add or replace an editor-only cmap with a bounded Unicode format-12 table.
|
||||
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
|
||||
fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> Option<()> {
|
||||
if mappings.len() > 65536 {
|
||||
return None;
|
||||
}
|
||||
let count = u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?) as usize;
|
||||
if count > 256 {
|
||||
if count == 0 || count > 256 {
|
||||
return None;
|
||||
}
|
||||
let mut entry = None;
|
||||
@@ -236,7 +255,10 @@ fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap
|
||||
head = Some(u32::from_be_bytes(record.get(8..12)?.try_into().ok()?) as usize);
|
||||
}
|
||||
}
|
||||
let entry = entry?;
|
||||
let (entry, head) = match entry {
|
||||
Some(entry) => (entry, head),
|
||||
None => insert_editor_cmap_slot(bytes, count, head)?,
|
||||
};
|
||||
let mut cmap = Vec::with_capacity(28 + mappings.len() * 12);
|
||||
cmap.extend_from_slice(&[0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12, 0, 12, 0, 0]);
|
||||
cmap.extend_from_slice(&(16_u32 + u32::try_from(mappings.len()).ok()? * 12).to_be_bytes());
|
||||
@@ -264,6 +286,17 @@ fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap
|
||||
bytes.get_mut(entry + 8..entry + 12)?.copy_from_slice(&offset.to_be_bytes());
|
||||
bytes.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(cmap.len()).ok()?.to_be_bytes());
|
||||
bytes.extend_from_slice(&cmap);
|
||||
// SFNT directories are sorted by tag. Some readers tolerate the unsorted directory of
|
||||
// a PDF subset, but shaping engines may binary-search it and otherwise miss the cmap.
|
||||
// Adding a slot increased the directory count; include the new record in this sort.
|
||||
let tables = usize::from(u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?));
|
||||
let end = 12usize.checked_add(tables.checked_mul(16)?)?;
|
||||
let mut records: Vec<[u8; 16]> = bytes.get(12..end)?.as_chunks::<16>().0.to_vec();
|
||||
records.sort_by_key(|r| [r[0], r[1], r[2], r[3]]);
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
let at = 12 + index * 16;
|
||||
bytes.get_mut(at..at + 16)?.copy_from_slice(record);
|
||||
}
|
||||
if let Some(head) = head {
|
||||
bytes.get_mut(head.checked_add(8)?..head.checked_add(12)?)?.fill(0);
|
||||
let adjustment = 0xb1b0afba_u32.wrapping_sub(checksum(bytes));
|
||||
@@ -272,6 +305,42 @@ fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Reserve a new cmap directory slot, retaining every original table payload.
|
||||
fn insert_editor_cmap_slot(bytes: &mut Vec<u8>, mut count: usize, mut head: Option<usize>) -> Option<(usize, Option<usize>)> {
|
||||
if count >= 256 {
|
||||
return None;
|
||||
}
|
||||
let end = 12usize.checked_add(count.checked_mul(16)?)?;
|
||||
bytes.get(..end)?;
|
||||
// Keep all original table bytes, shifting them by one directory record.
|
||||
for i in 0..count {
|
||||
let at = 12 + i * 16;
|
||||
let offset = u32::from_be_bytes(bytes.get(at + 8..at + 12)?.try_into().ok()?);
|
||||
let len = u32::from_be_bytes(bytes.get(at + 12..at + 16)?.try_into().ok()?);
|
||||
if (offset as usize) < end {
|
||||
return None;
|
||||
}
|
||||
bytes.get(offset as usize..offset.checked_add(len)? as usize)?;
|
||||
}
|
||||
bytes.splice(end..end, [0; 16]);
|
||||
for i in 0..count {
|
||||
let at = 12 + i * 16;
|
||||
let offset = u32::from_be_bytes(bytes.get(at + 8..at + 12)?.try_into().ok()?).checked_add(16)?;
|
||||
bytes.get_mut(at + 8..at + 12)?.copy_from_slice(&offset.to_be_bytes());
|
||||
}
|
||||
head = head.and_then(|offset| offset.checked_add(16));
|
||||
bytes.get_mut(end..end + 4)?.copy_from_slice(b"cmap");
|
||||
count += 1;
|
||||
let count = u16::try_from(count).ok()?;
|
||||
let selector = count.ilog2() as u16;
|
||||
let range = (1u16.checked_shl(u32::from(selector))?).checked_mul(16)?;
|
||||
bytes.get_mut(4..6)?.copy_from_slice(&count.to_be_bytes());
|
||||
bytes.get_mut(6..8)?.copy_from_slice(&range.to_be_bytes());
|
||||
bytes.get_mut(8..10)?.copy_from_slice(&selector.to_be_bytes());
|
||||
bytes.get_mut(10..12)?.copy_from_slice(&count.checked_mul(16)?.checked_sub(range)?.to_be_bytes());
|
||||
Some((end, head))
|
||||
}
|
||||
|
||||
fn descriptor_flags(doc: &Document, descriptor: &Dict) -> u32 {
|
||||
let value = descriptor.get(b"Flags").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) as i64;
|
||||
u32::try_from(value).unwrap_or(0)
|
||||
@@ -887,6 +956,19 @@ impl Metrics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the embedded program draws an outline for `code`. Known (`Some`) only for a subset
|
||||
/// Type 0 font over a readable TrueType program, where a dropped glyph is left empty; `None`
|
||||
/// when the program can't tell, for example a simple or CFF font. [`Metrics::encode`] accepts
|
||||
/// any code with a `/W` entry (#1443), so a caller that would rather use another subset of the
|
||||
/// same face than draw nothing asks this too. An empty space glyph is normal.
|
||||
pub fn has_outline(&self, code: u32) -> Option<bool> {
|
||||
if !self.composite || self.outlined_cids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cid = self.cid(code);
|
||||
Some(self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1))
|
||||
}
|
||||
|
||||
/// Whether `code` shows exactly `c` and has a glyph for it.
|
||||
fn shows(&self, code: u32, c: char) -> bool {
|
||||
self.unicode.text(code).is_some_and(|t| t.chars().eq(std::iter::once(c))) && self.has_glyph(code)
|
||||
@@ -1510,3 +1592,7 @@ mod encoding_tests {
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
mod thai;
|
||||
use thai::composite_glyph_maps;
|
||||
pub use thai::{ExistingThaiRun, LegacyThaiFont, recovered_thai_map};
|
||||
|
||||
@@ -892,3 +892,44 @@ fn low_codes_resolve_to_the_covering_range() {
|
||||
assert_eq!(covered, [5, 6, 7, 100, 101]);
|
||||
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap();
|
||||
let mut subset = original.to_vec();
|
||||
let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize;
|
||||
let records: Vec<_> = subset[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|r| &r[..4] != b"cmap").map(|r| r.to_vec()).collect();
|
||||
subset[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes());
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
subset[12 + index * 16..28 + index * 16].copy_from_slice(record);
|
||||
}
|
||||
assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err());
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut gids = vec![0; 36];
|
||||
gids[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes());
|
||||
let gids = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &gids)));
|
||||
let mut descendant = Dict::new();
|
||||
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids));
|
||||
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar")));
|
||||
let source = font(
|
||||
&mut doc,
|
||||
vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("Encoding", Object::name("Identity-H")),
|
||||
("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])),
|
||||
("ToUnicode", Object::Ref(unicode)),
|
||||
],
|
||||
);
|
||||
let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too");
|
||||
assert_eq!(shaped.glyphs[0].id, expected);
|
||||
let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") };
|
||||
assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
//! Recover a narrowly identified legacy Thai exporter map, not arbitrary missing Unicode.
|
||||
//!
|
||||
//! Some THSarabunPSK Identity-H subsets retain original glyph IDs but write Latin/control
|
||||
//! destinations for Thai in ToUnicode (including an accidental 00ff..01ff range). The intact
|
||||
//! Thai anchors identify the original nominal Thai order. Only that complete fingerprint is
|
||||
//! accepted, and then only when every glyph the subset kept advances as its assumed character
|
||||
//! does (a mark takes none); correct maps and unrelated fonts are never changed. No font
|
||||
//! artwork is bundled.
|
||||
|
||||
use super::{Dict, Document, Metrics, dict, only_char, unicode_map};
|
||||
use pdfcraft_cos::{DecodeBudget, Object};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Recover the nominal Thai codes and the presentation marks of this damaged legacy export.
|
||||
/// The caller must record the repair, and attach the map to a copy of the page's font.
|
||||
pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option<Vec<(u16, char)>> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let base = String::from_utf8_lossy(font.name(b"BaseFont")?);
|
||||
let family = base.rsplit('+').next()?;
|
||||
if !matches!(family, "THSarabunPSK" | "THSarabunPSK-Bold" | "THSarabunPSK-Italic" | "THSarabunPSK-BoldItalic")
|
||||
|| font.name(b"Subtype") != Some(b"Type0")
|
||||
|| font.name(b"Encoding") != Some(b"Identity-H")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let desc = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
if desc.name(b"CIDToGIDMap") != Some(b"Identity") {
|
||||
return None;
|
||||
}
|
||||
let map = unicode_map(doc, font, true);
|
||||
// Five separated consonants and three contextual marks must agree, together with the
|
||||
// corrupt destinations and corrupt range. This is deliberately stricter than font-name
|
||||
// detection: a renamed/subsetted font with different glyph order must not be guessed.
|
||||
let fingerprint = [
|
||||
(217, '\u{0e04}'),
|
||||
(226, '\u{0e0d}'),
|
||||
(250, '\u{0e25}'),
|
||||
(256, '\u{0e2b}'),
|
||||
(258, '\u{0e2d}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(358, '\u{0e31}'),
|
||||
(360, '\u{0e47}'),
|
||||
(214, '\u{000f}'),
|
||||
(240, ')'),
|
||||
(248, '#'),
|
||||
(261, '>'),
|
||||
(263, '@'),
|
||||
(265, '>'),
|
||||
(273, 'N'),
|
||||
(352, 'N'),
|
||||
(353, 'O'),
|
||||
];
|
||||
let matches = |pairs: &[(u32, char)], last: u16| {
|
||||
pairs.iter().all(|&(code, ch)| map.text(code).is_some_and(|t| t.chars().eq([ch])))
|
||||
&& map.ranges.iter().any(|r| r.lo == 255 && r.hi == 511 && r.units.as_ref() == [last])
|
||||
};
|
||||
// The same exporter emitted three distinct subset maps. Each must match its entire
|
||||
// intact-anchor and corrupt-destination fingerprint, including the oversized range.
|
||||
let body = matches(&fingerprint, 0x32);
|
||||
let attachment = matches(
|
||||
&[
|
||||
(217, '\u{0e04}'),
|
||||
(256, '\u{0e2b}'),
|
||||
(258, '\u{0e2d}'),
|
||||
(297, '\u{0e58}'),
|
||||
(347, '\u{0e48}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(358, '\u{0e31}'),
|
||||
(360, '\u{0e47}'),
|
||||
(214, '\u{000f}'),
|
||||
(226, '\u{000f}'),
|
||||
(240, '\u{0027}'),
|
||||
(248, '/'),
|
||||
(250, '1'),
|
||||
(265, 'B'),
|
||||
(273, 'N'),
|
||||
(352, 'N'),
|
||||
(353, 'O'),
|
||||
],
|
||||
0x32,
|
||||
);
|
||||
let bold = matches(
|
||||
&[
|
||||
(217, '\u{0e04}'),
|
||||
(226, '\u{0e0d}'),
|
||||
(232, '\u{0e13}'),
|
||||
(234, '\u{0e15}'),
|
||||
(236, '\u{0e17}'),
|
||||
(243, '\u{0e1e}'),
|
||||
(250, '\u{0e25}'),
|
||||
(256, '\u{0e2b}'),
|
||||
(258, '\u{0e2d}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(214, '\u{000f}'),
|
||||
(240, '!'),
|
||||
(248, '1'),
|
||||
(263, '@'),
|
||||
(273, 'N'),
|
||||
(352, 'N'),
|
||||
(353, 'O'),
|
||||
],
|
||||
0x30,
|
||||
);
|
||||
if !(body || attachment || bold) {
|
||||
return None;
|
||||
}
|
||||
let nominal = (214u16..=298).filter_map(|gid| {
|
||||
let unicode = u32::from(gid) + if gid <= 271 { 0xd2b } else { 0xd2f };
|
||||
char::from_u32(unicode).map(|ch| (gid, ch))
|
||||
});
|
||||
// Contextual marks have the same character as the nominal mark, with a moved outline.
|
||||
let contextual = [
|
||||
(343, '\u{0e34}'),
|
||||
(344, '\u{0e35}'),
|
||||
(347, '\u{0e48}'),
|
||||
(351, '\u{0e4c}'),
|
||||
(352, '\u{0e48}'),
|
||||
(353, '\u{0e49}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(358, '\u{0e31}'),
|
||||
(360, '\u{0e47}'),
|
||||
];
|
||||
// The anchors identify the exporter; the font program confirms each glyph's advance class
|
||||
// (not its identity, which nothing in such a subset records). Every glyph the
|
||||
// subset kept must advance as the character it is given (a vowel or tone mark set on its
|
||||
// base takes no advance, every other character does), else the order differs somewhere and
|
||||
// nothing is mapped. A glyph the subset dropped is left unmapped: no code can draw it, and
|
||||
// its advance is the subsetter's filler, not the glyph's.
|
||||
let program = embedded_truetype(doc, &desc)?;
|
||||
let face = skrifa::FontRef::new(&program).ok()?;
|
||||
let loca = face.loca(None).ok()?;
|
||||
let advances = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
|
||||
let mut out = Vec::new();
|
||||
for (gid, ch) in nominal.chain(contextual) {
|
||||
let at = usize::from(gid);
|
||||
if loca.get_raw(at).zip(loca.get_raw(at + 1)).is_none_or(|(start, end)| end <= start) {
|
||||
continue;
|
||||
}
|
||||
if advances.advance_width(skrifa::GlyphId::new(u32::from(gid))).is_none_or(|w| (w == 0.0) != is_thai_mark(ch)) {
|
||||
return None;
|
||||
}
|
||||
out.push((gid, ch));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The character each legacy Windows Thai presentation form U+F700 + index stands for: ฐ and ญ
|
||||
/// without descenders, and the vowels and tone marks moved left, lower or both (the PUA
|
||||
/// assignments of <https://linux.thai.net/~thep/th-otf/shaping.html>).
|
||||
const PUA_BASES: [char; 27] = ['ฐ', 'ิ', 'ี', 'ึ', 'ื', '่', '้', '๊', '๋', '์', '่', '้', '๊', '๋', '์', 'ญ', 'ั', 'ํ', '็', '่', '้', '๊', '๋', '์', 'ุ', 'ู', 'ฺ'];
|
||||
|
||||
/// Whether a Thai character is a vowel or tone mark set above or below its base, with no advance
|
||||
/// of its own (U+0E31, U+0E34–U+0E3A, U+0E47–U+0E4E). Sara Am and the other vowels advance.
|
||||
fn is_thai_mark(ch: char) -> bool {
|
||||
matches!(u32::from(ch), 0x0e31 | 0x0e34..=0x0e3a | 0x0e47..=0x0e4e)
|
||||
}
|
||||
|
||||
/// The glyph a legacy THSarabunPSK program draws Thai character `ch` with: U+0E01–U+0E3A are
|
||||
/// glyphs 214–271 and U+0E3F–U+0E5B glyphs 272–298 (the order `recovered_thai_map` assumes).
|
||||
fn legacy_glyph(ch: u32) -> Option<u32> {
|
||||
match ch {
|
||||
0x0e01..=0x0e3a => ch.checked_sub(0xd2b),
|
||||
0x0e3f..=0x0e5b => ch.checked_sub(0xd2f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The TrueType program (`/FontFile2`) of a CIDFont, decoded within the editor's size cap.
|
||||
fn embedded_truetype(doc: &Document, descendant: &Dict) -> Option<Vec<u8>> {
|
||||
let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?;
|
||||
let program = doc.resolve(descriptor.get(b"FontFile2")?);
|
||||
let Object::Stream(program) = program.as_ref() else { return None };
|
||||
DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok()
|
||||
}
|
||||
|
||||
/// Source glyph positions and the original PDF character codes that draw them. Empty codes
|
||||
/// represent whitespace advances: no glyph is added to the font or substituted for a space.
|
||||
pub struct ExistingThaiRun {
|
||||
pub shaped: crate::source_shaping::ShapedRun,
|
||||
pub codes: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Unicode-to-glyph and glyph-to-PDF-code maps for a two-byte Identity-H TrueType font.
|
||||
/// Used by the editor-only cmap adapter and by direct source-font shaping.
|
||||
pub(super) fn composite_glyph_maps(
|
||||
doc: &Document,
|
||||
font: &Dict,
|
||||
bytes: &[u8],
|
||||
metrics: &Metrics,
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Option<(BTreeMap<u32, u32>, BTreeMap<u32, u16>)> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
if font.name(b"Encoding") != Some(b"Identity-H") {
|
||||
return None;
|
||||
}
|
||||
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() {
|
||||
None => None,
|
||||
Some(Object::Name(n)) if n == b"Identity" => None,
|
||||
Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?),
|
||||
_ => return None,
|
||||
};
|
||||
let face = skrifa::FontRef::new(bytes).ok()?;
|
||||
let loca = face.loca(None).ok()?;
|
||||
let advances = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
|
||||
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(a, b)| b > a);
|
||||
let glyph_of = |cid: u16| -> Option<u32> {
|
||||
match &cid_map {
|
||||
None => Some(u32::from(cid)),
|
||||
Some(map) => {
|
||||
let at = usize::from(cid).checked_mul(2)?;
|
||||
Some(u32::from(u16::from_be_bytes(map.get(at..at + 2)?.try_into().ok()?)))
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut unicode = BTreeMap::new();
|
||||
let mut codes = BTreeMap::new();
|
||||
for code in 0..=u16::MAX {
|
||||
let Some(gid) = glyph_of(code) else { continue };
|
||||
if gid == 0 || gid as usize >= loca.len().saturating_sub(1) {
|
||||
continue;
|
||||
}
|
||||
let text = metrics.text_of(u32::from(code));
|
||||
let ch = text.as_deref().and_then(only_char);
|
||||
let space = ch == Some(' ') && advances.advance_width(skrifa::GlyphId::new(gid)).is_some_and(|w| w > 0.0);
|
||||
if !outlined(gid) && !space {
|
||||
continue;
|
||||
}
|
||||
codes.entry(gid).or_insert(code);
|
||||
if let Some(ch) = ch.filter(|c| !c.is_control()) {
|
||||
unicode.entry(u32::from(ch)).and_modify(|old: &mut u32| *old = (*old).min(gid)).or_insert(gid);
|
||||
}
|
||||
}
|
||||
// The verified THSarabunPSK glyph order is retained by these PDF subsets. Restore the
|
||||
// Windows Thai PUA alternates for HarfRust's legacy shaper, only where outlines survive.
|
||||
// See https://linux.thai.net/~thep/th-otf/shaping.html (legacy PUA assignments).
|
||||
let family = metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font);
|
||||
let alternate_meanings = metrics.text_of(352).is_some_and(|t| t == "่") && metrics.text_of(353).is_some_and(|t| t == "้");
|
||||
// Each alternate the subset kept must advance as its form does: U+F700 and U+F70F are ฐ and
|
||||
// ญ without their descenders, the rest marks with no advance of their own. Any that doesn't
|
||||
// means another glyph order, and then none is used: the wrong mark would draw silently.
|
||||
let kept: Vec<(usize, u32)> = (342u32..=368).enumerate().filter(|&(_, gid)| outlined(gid)).collect();
|
||||
let in_order =
|
||||
kept.iter().all(|&(index, gid)| advances.advance_width(skrifa::GlyphId::new(gid)).is_some_and(|w| (w > 0.0) == matches!(index, 0 | 15)));
|
||||
// Where the PDF says what an alternate's code is (an intact ToUnicode gives the character it
|
||||
// stands for), it must be that alternate's base character: two alternates of the same kind
|
||||
// in another order pass the advance check, but not this one.
|
||||
let code_of = |gid: u32| match &cid_map {
|
||||
None => u16::try_from(gid).ok(),
|
||||
Some(_) => codes.get(&gid).copied(),
|
||||
};
|
||||
let meanings_agree = kept.iter().all(|&(index, gid)| {
|
||||
let stated = code_of(gid).and_then(|code| metrics.text_of(u32::from(code))).and_then(|t| only_char(&t));
|
||||
stated.filter(|c| matches!(u32::from(*c), 0x0e01..=0x0e5b)).is_none_or(|c| PUA_BASES.get(index) == Some(&c))
|
||||
});
|
||||
if family.starts_with("THSarabunPSK")
|
||||
&& unicode.get(&0x0e01) == Some(&214)
|
||||
&& unicode.get(&0x0e2d) == Some(&258)
|
||||
&& (alternate_meanings || unicode.contains_key(&0xf70a))
|
||||
&& in_order
|
||||
&& meanings_agree
|
||||
{
|
||||
for (index, gid) in kept {
|
||||
let code = match &cid_map {
|
||||
None => u16::try_from(gid).ok(),
|
||||
Some(_) => codes.get(&gid).copied(),
|
||||
};
|
||||
if let Some(code) = code {
|
||||
unicode.insert(0xf700 + index as u32, gid);
|
||||
codes.entry(gid).or_insert(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((unicode, codes))
|
||||
}
|
||||
|
||||
/// Shape Thai in a THSarabunPSK source font's own outlines and return codes for that same font.
|
||||
/// No font program, family, weight or size is replaced. A program with OpenType Thai mark
|
||||
/// positioning (a complete face loaded with Load font, say) is shaped by that layout; one
|
||||
/// without it must be a verified legacy positional program, shaped with its nominal glyphs and
|
||||
/// Windows PUA alternates. A subset missing a glyph the text needs is refused, so the caller can
|
||||
/// try another subset of the same face or ask for its full font.
|
||||
pub struct LegacyThaiFont {
|
||||
bytes: Vec<u8>,
|
||||
codes: BTreeMap<u32, u16>,
|
||||
space: f64,
|
||||
/// The program positions Thai marks itself (GPOS `mark`/`mkmk`).
|
||||
positioned: bool,
|
||||
}
|
||||
|
||||
impl LegacyThaiFont {
|
||||
/// Read and verify the source font once for all wrapping/preview measurements. `None` for
|
||||
/// another family, a font with no Thai, and a program without Thai mark positioning whose
|
||||
/// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper
|
||||
/// assumes can't be trusted there, so it is shaped as an ordinary source font.
|
||||
///
|
||||
/// Its streams are decoded against `budget`, which the caller shares between every font it
|
||||
/// reads: what they keep is the programs decoded and the editor cmaps added to them.
|
||||
pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result<Option<Self>, String> {
|
||||
let m = Metrics::from_dict(doc, font);
|
||||
if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") {
|
||||
return Ok(None);
|
||||
}
|
||||
if !space.is_finite() || space < 0.0 {
|
||||
return Err("the source space width is invalid".into());
|
||||
}
|
||||
let mut bytes = super::embedded_sfnt_within(doc, font, budget).ok_or("the source Thai font has no readable glyph program")?;
|
||||
let positioned = crate::source_shaping::has_thai_layout(&bytes);
|
||||
let (unicode, codes) = composite_glyph_maps(doc, font, &bytes, &m, budget).ok_or("the Thai source glyph codes cannot be recovered safely")?;
|
||||
// A subset keeps only the glyphs its document used, so no particular letter or alternate
|
||||
// is required here: [`Self::shape`] refuses a text whose glyphs, or required alternates
|
||||
// ([`require_pua`]), the subset lacks. Without its own positioning, every Thai glyph it
|
||||
// does keep must be where the legacy order puts that character, or one of the PUA
|
||||
// alternates for a mark (or for ฐ and ญ without their descenders).
|
||||
let mut thai = unicode.range(0x0e01..=0x0e5b).peekable();
|
||||
if thai.peek().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let usable = positioned
|
||||
|| thai.all(|(&ch, &gid)| {
|
||||
legacy_glyph(ch) == Some(gid)
|
||||
|| ((342..=368).contains(&gid) && (char::from_u32(ch).is_some_and(is_thai_mark) || matches!(ch, 0x0e0d | 0x0e10)))
|
||||
});
|
||||
if !usable {
|
||||
return Ok(None);
|
||||
}
|
||||
if !positioned {
|
||||
// Glyphs 342–368 are alternates moved for a context. Mapped as the nominal character
|
||||
// (its glyph dropped), one would be drawn out of place where the nominal form is
|
||||
// needed: the shaper reaches it only through its PUA form, and that text is refused.
|
||||
use skrifa::MetadataProvider as _;
|
||||
let alternate = |u: u32, g: skrifa::GlyphId| matches!(u, 0x0e01..=0x0e5b) && (342..=368).contains(&g.to_u32());
|
||||
let charmap = skrifa::FontRef::new(&bytes).map_err(|e| e.to_string())?.charmap();
|
||||
if charmap.mappings().any(|(u, g)| alternate(u, g)) {
|
||||
let nominal: BTreeMap<u32, u32> =
|
||||
charmap.mappings().filter(|&(u, g)| g.to_u32() != 0 && !alternate(u, g)).take(65_537).map(|(u, g)| (u, g.to_u32())).collect();
|
||||
super::append_editor_cmap(&mut bytes, &nominal).ok_or("the Thai source glyph map cannot be rebuilt safely")?;
|
||||
}
|
||||
}
|
||||
Ok(Some(Self { bytes, codes, space, positioned }))
|
||||
}
|
||||
|
||||
/// Shape into glyphs that are already in this exact source face.
|
||||
pub fn shape(&self, text: &str) -> Result<ExistingThaiRun, String> {
|
||||
use skrifa::MetadataProvider as _;
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err("shape one bounded Thai source row at a time".into());
|
||||
}
|
||||
// Only the legacy shaper needs the PUA alternates; a positioned program moves its marks.
|
||||
if !self.positioned {
|
||||
let face = skrifa::FontRef::new(&self.bytes).map_err(|e| e.to_string())?;
|
||||
require_pua(&face.charmap(), text)?;
|
||||
}
|
||||
let mut shaped = crate::source_shaping::ShapedRun { glyphs: Vec::new(), advance: 0.0 };
|
||||
let mut codes = Vec::new();
|
||||
// Legacy PDF subsets often omit the space glyph. Keep its measured PDF advance as a
|
||||
// positioning gap, rather than drawing .notdef or changing to another font.
|
||||
for piece in text.split_inclusive(' ') {
|
||||
let word = piece.strip_suffix(' ').unwrap_or(piece);
|
||||
if !word.is_empty() {
|
||||
let run = if self.positioned {
|
||||
crate::source_shaping::shape(&self.bytes, word)?
|
||||
} else {
|
||||
crate::source_shaping::shape_legacy_thai(&self.bytes, word)?
|
||||
};
|
||||
for mut glyph in run.glyphs {
|
||||
let code = self
|
||||
.codes
|
||||
.get(&glyph.id.to_u32())
|
||||
.ok_or("the Thai subset lacks a required contextual glyph; load the complete original face")?;
|
||||
glyph.position[0] += shaped.advance;
|
||||
codes.push(code.to_be_bytes().to_vec());
|
||||
shaped.glyphs.push(glyph);
|
||||
}
|
||||
shaped.advance += run.advance;
|
||||
}
|
||||
if piece.ends_with(' ') {
|
||||
shaped.glyphs.push(crate::source_shaping::ShapedGlyph {
|
||||
id: skrifa::GlyphId::NOTDEF,
|
||||
position: [shaped.advance, 0.0],
|
||||
text: " ".into(),
|
||||
});
|
||||
codes.push(Vec::new());
|
||||
shaped.advance += self.space;
|
||||
}
|
||||
if shaped.glyphs.len() > 65_534 || !shaped.advance.is_finite() {
|
||||
return Err("Thai source row exceeds the shaping budget".into());
|
||||
}
|
||||
}
|
||||
Ok(ExistingThaiRun { shaped, codes })
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify every context-dependent PUA alternate before asking the legacy shaper to use it.
|
||||
/// Otherwise a subset missing an alternate would silently draw the wrong high/left mark.
|
||||
fn require_pua(cmap: &skrifa::charmap::Charmap<'_>, text: &str) -> Result<(), String> {
|
||||
let mut base = 0u32;
|
||||
let mut above = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
let c = u32::from(ch);
|
||||
let ascender = matches!(base, 0xe1b | 0xe1d | 0xe1f);
|
||||
let pua = match c {
|
||||
0xe31 if ascender => Some(0xf710),
|
||||
0xe34..=0xe37 if ascender => Some(0xf701 + c - 0xe34),
|
||||
0xe47 if ascender => Some(0xf712),
|
||||
0xe4d if ascender => Some(0xf711),
|
||||
0xe48..=0xe4c => {
|
||||
let high = above || chars.peek().is_some_and(|c| *c == 'ำ');
|
||||
if ascender {
|
||||
Some((if high { 0xf713 } else { 0xf705 }) + c - 0xe48)
|
||||
} else if !high {
|
||||
Some(0xf70a + c - 0xe48)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
0xe38..=0xe3a if matches!(base, 0xe0e | 0xe0f) => Some(0xf718 + c - 0xe38),
|
||||
0xe38..=0xe3a if base == 0xe0d => Some(0xf70f),
|
||||
0xe38..=0xe3a if base == 0xe10 => Some(0xf700),
|
||||
0xe33 if ascender => Some(0xf711),
|
||||
_ => None,
|
||||
};
|
||||
if pua.is_some_and(|p| char::from_u32(p).is_none_or(|c| cmap.map(c).is_none())) {
|
||||
return Err(format!(
|
||||
"the Thai subset lacks a required contextual mark for {ch} (U+{c:04X}); load the complete original face or explicitly select a replacement font"
|
||||
));
|
||||
}
|
||||
if matches!(c, 0xe01..=0xe2e) {
|
||||
base = c;
|
||||
above = false;
|
||||
} else if matches!(c, 0xe31 | 0xe34..=0xe37 | 0xe47 | 0xe4d..=0xe4e) {
|
||||
above = true;
|
||||
} else if !matches!(c, 0xe38..=0xe3a | 0xe48..=0xe4c) {
|
||||
base = 0;
|
||||
above = false;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LegacyThaiFont, Metrics, composite_glyph_maps, is_thai_mark, recovered_thai_map};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Where table `tag` starts in an sfnt.
|
||||
fn table(program: &[u8], tag: &[u8; 4]) -> usize {
|
||||
let count = u16::from_be_bytes([program[4], program[5]]) as usize;
|
||||
let record = (0..count).map(|i| 12 + i * 16).find(|&r| &program[r..r + 4] == tag).unwrap();
|
||||
u32::from_be_bytes(program[record + 8..record + 12].try_into().unwrap()) as usize
|
||||
}
|
||||
|
||||
/// Set glyph `gid`'s advance (a long `hmtx` entry).
|
||||
fn set_advance(program: &mut [u8], gid: usize, advance: u16) {
|
||||
let long = u16::from_be_bytes(program[table(program, b"hhea") + 34..][..2].try_into().unwrap()) as usize;
|
||||
assert!(gid < long, "a long hmtx entry");
|
||||
let at = table(program, b"hmtx") + gid * 4;
|
||||
program[at..at + 2].copy_from_slice(&advance.to_be_bytes());
|
||||
}
|
||||
|
||||
/// Empty glyph `gid`, as a subsetter drops it: its data is left as the previous glyph's padding.
|
||||
fn drop_glyph(program: &mut [u8], gid: usize) {
|
||||
let long = program[table(program, b"head") + 51] == 1;
|
||||
let loca = table(program, b"loca");
|
||||
if long {
|
||||
let next: [u8; 4] = program[loca + (gid + 1) * 4..][..4].try_into().unwrap();
|
||||
program[loca + gid * 4..][..4].copy_from_slice(&next);
|
||||
} else {
|
||||
let next: [u8; 2] = program[loca + (gid + 1) * 2..][..2].try_into().unwrap();
|
||||
program[loca + gid * 2..][..2].copy_from_slice(&next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inter's outlines standing in for a THSarabunPSK subset (no proprietary font is used): the
|
||||
/// glyphs where the exporter's order has a Thai mark or a mark-like alternate take no
|
||||
/// advance, the others 500 units.
|
||||
fn thai_ordered_program() -> Vec<u8> {
|
||||
let mut program = crate::script::INTER_REGULAR.to_vec();
|
||||
for gid in 214..=368usize {
|
||||
let mark = match gid {
|
||||
214..=298 => is_thai_mark(char::from_u32(gid as u32 + if gid <= 271 { 0xd2b } else { 0xd2f }).unwrap()),
|
||||
342..=368 => !matches!(gid, 342 | 357),
|
||||
_ => continue,
|
||||
};
|
||||
set_advance(&mut program, gid, if mark { 0 } else { 500 });
|
||||
}
|
||||
program
|
||||
}
|
||||
|
||||
/// A THSarabunPSK Identity-H font over `program`, with `to_unicode`.
|
||||
fn thai_font(doc: &mut Document, program: &[u8], to_unicode: &[u8]) -> Dict {
|
||||
let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program))));
|
||||
thai_font_over(doc, file, Object::name("Identity"), to_unicode)
|
||||
}
|
||||
|
||||
/// [`thai_font`] over the `/FontFile2` stream `file` and the `/CIDToGIDMap` `cid_to_gid`.
|
||||
fn thai_font_over(doc: &mut Document, file: Object, cid_to_gid: Object, to_unicode: &[u8]) -> Dict {
|
||||
let tu = doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), file);
|
||||
let mut desc = Dict::new();
|
||||
desc.set(b"CIDToGIDMap".to_vec(), cid_to_gid);
|
||||
desc.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+THSarabunPSK"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)]));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(tu));
|
||||
font
|
||||
}
|
||||
|
||||
/// `data` as a Flate stream under a PNG predictor whose `/DecodeParms` is given by reference:
|
||||
/// read without that dictionary, each row's tag byte stays in the decoded bytes.
|
||||
fn predicted(doc: &mut Document, data: &[u8]) -> Object {
|
||||
let rows: Vec<u8> = data.iter().flat_map(|byte| [0, *byte]).collect();
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(1));
|
||||
let parms = doc.add(Object::Dict(parms));
|
||||
let mut stream = Stream::flate(Dict::new(), &rows);
|
||||
stream.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
|
||||
Object::Ref(doc.add(Object::Stream(stream)))
|
||||
}
|
||||
|
||||
/// An intact legacy map: nominal Thai and two tone alternates.
|
||||
const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
|
||||
/// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai
|
||||
/// codes, and the oversized range first.
|
||||
const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> <ffff> endcodespacerange \
|
||||
1 beginbfrange <00ff><01ff><0032> endbfrange 17 beginbfrange \
|
||||
<00d9><00d9><0e04> <00e2><00e2><0e0d> <00fa><00fa><0e25> <0100><0100><0e2b> <0102><0102><0e2d> \
|
||||
<0164><0164><0e4c> <0166><0166><0e31> <0168><0168><0e47> \
|
||||
<00d6><00d6><000f> <00f0><00f0><0029> <00f8><00f8><0023> <0105><0105><003e> <0107><0107><0040> \
|
||||
<0109><0109><003e> <0111><0111><004e> <0160><0160><004e> <0161><0161><004f> endbfrange endcmap";
|
||||
|
||||
/// The anchors identify the exporter, but the glyph order is still checked glyph by glyph: a
|
||||
/// mark where a letter belongs (or the reverse) anywhere in the kept glyphs refuses the whole
|
||||
/// map, and a glyph the subset dropped is left unmapped whatever its filler advance says.
|
||||
#[test]
|
||||
fn recovered_thai_map_checks_every_kept_glyph_against_the_font_program() {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &thai_ordered_program(), DAMAGED);
|
||||
let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect();
|
||||
assert_eq!(recovered[&262], 'ั');
|
||||
assert_eq!(recovered[&297], '๘');
|
||||
// 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that
|
||||
// put Mai Han-akat one glyph earlier passes every anchor and only this check sees it.
|
||||
let mut shifted = thai_ordered_program();
|
||||
set_advance(&mut shifted, 261, 0);
|
||||
set_advance(&mut shifted, 262, 500);
|
||||
let shifted = thai_font(&mut doc, &shifted, DAMAGED);
|
||||
assert!(recovered_thai_map(&doc, &shifted).is_none(), "a kept glyph that advances unlike its character refuses the map");
|
||||
let mut dropped = thai_ordered_program();
|
||||
drop_glyph(&mut dropped, 262);
|
||||
set_advance(&mut dropped, 262, 500);
|
||||
let dropped = thai_font(&mut doc, &dropped, DAMAGED);
|
||||
let recovered = recovered_thai_map(&doc, &dropped).unwrap();
|
||||
assert!(recovered.iter().all(|(gid, _)| *gid != 262), "a dropped glyph is not mapped");
|
||||
assert!(recovered.iter().any(|(gid, _)| *gid == 263));
|
||||
let mut unreadable = font.clone();
|
||||
let Some(Object::Array(desc)) = unreadable.get(b"DescendantFonts").cloned() else { panic!("fixture") };
|
||||
let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") };
|
||||
desc.remove(b"FontDescriptor");
|
||||
unreadable.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)]));
|
||||
assert!(recovered_thai_map(&doc, &unreadable).is_none(), "without the program nothing is verified, so nothing is mapped");
|
||||
}
|
||||
|
||||
/// `program` without the tables a legacy subsetter leaves out.
|
||||
fn without_tables(program: &[u8], tags: &[&[u8; 4]]) -> Vec<u8> {
|
||||
let mut program = program.to_vec();
|
||||
let count = u16::from_be_bytes([program[4], program[5]]) as usize;
|
||||
let records: Vec<Vec<u8>> =
|
||||
program[12..12 + count * 16].chunks(16).filter(|r| !tags.iter().any(|t| &r[..4] == t.as_slice())).map(<[u8]>::to_vec).collect();
|
||||
program[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes());
|
||||
for (i, record) in records.iter().enumerate() {
|
||||
program[12 + i * 16..28 + i * 16].copy_from_slice(record);
|
||||
}
|
||||
program
|
||||
}
|
||||
|
||||
/// The font program and `/CIDToGIDMap` are read with `/DecodeParms` given by reference, as a
|
||||
/// viewer reads them: the map repair, the glyph codes and the shaper see the real bytes.
|
||||
#[test]
|
||||
fn thai_font_streams_are_read_with_decode_parms_given_by_reference() {
|
||||
let stripped = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]);
|
||||
let mut doc = Document::new_empty();
|
||||
let program = predicted(&mut doc, &stripped);
|
||||
let damaged = thai_font_over(&mut doc, program.clone(), Object::name("Identity"), DAMAGED);
|
||||
assert!(recovered_thai_map(&doc, &damaged).is_some(), "the program is read with its predictor");
|
||||
let gids: Vec<u8> = (0u16..=368).flat_map(u16::to_be_bytes).collect();
|
||||
let map = predicted(&mut doc, &gids);
|
||||
let font = thai_font_over(&mut doc, program, map, INTACT);
|
||||
let metrics = Metrics::from_dict(&doc, &font);
|
||||
let (map, _) = composite_glyph_maps(&doc, &font, &stripped, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap();
|
||||
assert_eq!(map.get(&0x0e01), Some(&214), "the CIDToGIDMap is read with its predictor");
|
||||
let shaped =
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap();
|
||||
assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>(), [214, 352]);
|
||||
}
|
||||
|
||||
/// A legacy subset that kept a Latin-only cmap of its own still reaches its Thai glyphs through
|
||||
/// the PDF's verified maps, and keeps its own mappings.
|
||||
#[test]
|
||||
fn a_latin_only_cmap_does_not_hide_a_legacy_subsets_thai_glyphs() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let program = without_tables(&thai_ordered_program(), &[b"GSUB", b"GPOS"]);
|
||||
let latin = skrifa::FontRef::new(&program).unwrap().charmap().map('A').unwrap();
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &program, INTACT);
|
||||
let shaped =
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap();
|
||||
assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>(), [214, 352]);
|
||||
let editor = crate::pdf::embedded_sfnt(&doc, &font).unwrap();
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('A'), Some(latin), "the program's own mappings stay");
|
||||
}
|
||||
|
||||
/// Only Thai with a mark to set on its base needs the program's mark positioning: digits and
|
||||
/// bare letters shape without it.
|
||||
#[test]
|
||||
fn thai_without_marks_shapes_without_mark_positioning() {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]), INTACT);
|
||||
let program = crate::pdf::embedded_sfnt(&doc, &font).unwrap();
|
||||
let shaped = crate::source_shaping::shape(&program, "๑๒๓กข").expect("nothing to position");
|
||||
assert_eq!(shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>(), [290, 291, 292, 214, 215]);
|
||||
let error = crate::source_shaping::shape(&program, "กิ่").unwrap_err();
|
||||
assert!(error.contains("mark positioning"), "{error}");
|
||||
}
|
||||
|
||||
/// A legacy subset whose map gives a shifted alternate as the nominal mark (its nominal glyph
|
||||
/// dropped) draws it only where that alternate belongs, and refuses text needing the nominal.
|
||||
#[test]
|
||||
fn a_legacy_alternate_is_not_drawn_as_the_nominal_mark() {
|
||||
let mut program = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]);
|
||||
drop_glyph(&mut program, 265);
|
||||
let unicode = [INTACT, b" 1 beginbfchar <0157> <0e34> endbfchar"].concat();
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &program, &unicode);
|
||||
let font = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset");
|
||||
let ids = |text: &str| font.shape(text).map(|run| run.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>());
|
||||
assert_eq!(ids("ปิ"), Ok(vec![240, 343]), "after an ascender, the alternate is the form needed");
|
||||
assert!(ids("กิ").is_err(), "elsewhere the nominal ิ is needed, which the subset dropped");
|
||||
}
|
||||
|
||||
/// A THSarabunPSK program with its own Thai mark positioning is shaped by that layout; one
|
||||
/// without it only if every Thai glyph it keeps is where the legacy order has that character
|
||||
/// (the legacy shaper skips the positioning check). A subset needs no particular glyph.
|
||||
#[test]
|
||||
fn thai_source_programs_are_shaped_by_their_own_layout_or_as_verified_legacy() {
|
||||
let unicode = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
// `Some(positioned)` when the program can be shaped in place.
|
||||
let mode = |program: &[u8], unicode: &[u8]| {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, program, unicode);
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().map(|f| f.positioned)
|
||||
};
|
||||
let stripped = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]);
|
||||
assert_eq!(mode(&stripped, unicode), Some(false), "a legacy positional subset");
|
||||
let positioned = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB"]);
|
||||
assert_eq!(mode(&positioned, unicode), Some(true), "a face with Thai mark positioning uses it");
|
||||
// ก at another glyph than the legacy order's.
|
||||
let moved = b"2 beginbfrange <00d7> <010f> <0e02> <0110> <012a> <0e3f> endbfrange 1 beginbfchar <0024> <0e01> endbfchar";
|
||||
assert_eq!(mode(&stripped, moved), None, "out of the legacy order and without its own positioning");
|
||||
assert_eq!(mode(&positioned, moved), Some(true), "its own layout doesn't depend on the legacy order");
|
||||
// Some of the legacy glyphs, no ก or อ among them.
|
||||
assert_eq!(mode(&stripped, b"1 beginbfrange <00d7> <00e0> <0e02> endbfrange"), Some(false));
|
||||
assert_eq!(mode(&stripped, b"1 beginbfchar <0024> <0041> endbfchar"), None, "no Thai at all");
|
||||
}
|
||||
|
||||
/// The Windows PUA alternates are used only when each one the subset kept advances as its
|
||||
/// form does: one mark out of place withdraws them all, so the shaper refuses instead of
|
||||
/// drawing another mark.
|
||||
#[test]
|
||||
fn legacy_thai_alternates_need_their_glyph_order_confirmed() {
|
||||
let unicode = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
let alternates_with = |program: &[u8], unicode: &[u8]| {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, program, unicode);
|
||||
let metrics = Metrics::from_dict(&doc, &font);
|
||||
let (map, _) = composite_glyph_maps(&doc, &font, program, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap();
|
||||
map.range(0xf700..=0xf71a).map(|(u, g)| (*u, *g)).collect::<Vec<_>>()
|
||||
};
|
||||
let alternates = |program: &[u8]| alternates_with(program, unicode);
|
||||
let ordered = alternates(&thai_ordered_program());
|
||||
assert!(ordered.contains(&(0xf70a, 352)) && ordered.contains(&(0xf710, 358)), "{ordered:?}");
|
||||
let mut misplaced = thai_ordered_program();
|
||||
set_advance(&mut misplaced, 352, 500);
|
||||
assert!(alternates(&misplaced).is_empty(), "one misplaced alternate withdraws them all");
|
||||
// A dropped alternate is simply absent; the others still come from the kept glyphs.
|
||||
let mut dropped = thai_ordered_program();
|
||||
drop_glyph(&mut dropped, 353);
|
||||
let kept = alternates(&dropped);
|
||||
assert!(kept.contains(&(0xf70a, 352)) && !kept.iter().any(|(_, g)| *g == 353), "{kept:?}");
|
||||
// Two marks of the same kind (both zero-advance) in another order: the PDF's own map says
|
||||
// glyph 343 is ี and 344 is ิ, the reverse of U+F701 and U+F702. None is used.
|
||||
let stated = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e35> <0158> <0e34> <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
assert!(alternates_with(&thai_ordered_program(), stated).is_empty(), "a stated meaning out of the PUA order withdraws them all");
|
||||
let agreeing = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e34> <0158> <0e35> <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
assert!(alternates_with(&thai_ordered_program(), agreeing).contains(&(0xf701, 343)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_legacy_thai_map_recovers_consonants_vowels_and_contextual_tones_only_for_its_fingerprint() {
|
||||
let mut doc = Document::new_empty();
|
||||
let data = b"begincmap 1 begincodespacerange <0000> <ffff> endcodespacerange 18 beginbfrange \
|
||||
<00d9><00d9><0e04> <00e2><00e2><0e0d> <00fa><00fa><0e25> <0100><0100><0e2b> <0102><0102><0e2d> \
|
||||
<0164><0164><0e4c> <0166><0166><0e31> <0168><0168><0e47> \
|
||||
<00d6><00d6><000f> <00f0><00f0><0029> <00f8><00f8><0023> <0105><0105><003e> <0107><0107><0040> \
|
||||
<0109><0109><003e> <0111><0111><004e> <0160><0160><004e> <0161><0161><004f> <00ff><01ff><0032> endbfrange endcmap";
|
||||
let mut font = thai_font(&mut doc, &thai_ordered_program(), data);
|
||||
let tu = font.get(b"ToUnicode").and_then(Object::as_ref).unwrap();
|
||||
// The corrupt range is declared before explicit anchors in the actual exporter.
|
||||
let data = String::from_utf8_lossy(data)
|
||||
.replace("18 beginbfrange", "1 beginbfrange <00ff><01ff><0032> endbfrange 17 beginbfrange")
|
||||
.replace(" <00ff><01ff><0032> endbfrange endcmap", " endbfrange endcmap");
|
||||
doc.set(tu, Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), data.as_bytes())));
|
||||
let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect();
|
||||
let title: String = [240, 248, 261, 214, 263, 253].into_iter().map(|code| recovered[&code]).collect();
|
||||
assert_eq!(title, "ประกาศ");
|
||||
assert_eq!(recovered[&265], 'ิ');
|
||||
assert_eq!(recovered[&273], 'เ');
|
||||
assert_eq!(recovered[&352], '่');
|
||||
assert_eq!(recovered[&353], '้');
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+Unrelated"));
|
||||
assert!(recovered_thai_map(&doc, &font).is_none());
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+THSarabunPSK"));
|
||||
doc.set(tu, Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <00d9><0e04> endbfchar")));
|
||||
assert!(recovered_thai_map(&doc, &font).is_none(), "one plausible anchor cannot authorize guessing a map");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,45 @@ pub fn required(text: &str) -> bool {
|
||||
.any(|c| matches!(u32::from(c), 0x0300..=0x036f | 0x0590..=0x0fff | 0x1780..=0x17ff | 0x200c..=0x200f | 0x202a..=0x202e | 0x2066..=0x2069))
|
||||
}
|
||||
|
||||
/// Whether a Thai source program retains its mark positioning.
|
||||
/// Character coverage alone does not show that stacked vowels and tones can be laid out.
|
||||
/// OpenType Thai requires `mark`/`mkmk` (GPOS); `ccmp` substitution (GSUB) is optional.
|
||||
pub fn has_thai_layout(bytes: &[u8]) -> bool {
|
||||
FontRef::new(bytes).is_ok_and(|f| positions_thai_marks(&f))
|
||||
}
|
||||
|
||||
/// Whether the GPOS features the shaper uses for Thai include `mark` or `mkmk`. The selection is
|
||||
/// HarfRust's, through the same read-fonts calls: the `thai` script, else `DFLT`, `dflt` or
|
||||
/// `latn` (`ScriptList::select`); then, given no language, a language system tagged `dflt`,
|
||||
/// else the script's default one. Marks under a script or language it doesn't select don't
|
||||
/// count, nor does a GPOS with only kerning.
|
||||
fn positions_thai_marks(face: &FontRef) -> bool {
|
||||
use skrifa::Tag;
|
||||
let Ok(gpos) = face.gpos() else { return false };
|
||||
let (Ok(scripts), Ok(features)) = (gpos.script_list(), gpos.feature_list()) else { return false };
|
||||
let records = features.feature_records();
|
||||
let marks = |index: u16| records.get(usize::from(index)).is_some_and(|r| r.feature_tag() == *b"mark" || r.feature_tag() == *b"mkmk");
|
||||
let Some(selected) = scripts.select(&[Tag::new(b"thai")]) else { return false };
|
||||
let Ok(script) = scripts.get(selected.index) else { return false };
|
||||
let language = match script.lang_sys_index_for_tag(Tag::new(b"dflt")) {
|
||||
Some(index) => script.lang_sys(index).ok().map(|l| l.element),
|
||||
None => script.default_lang_sys().transpose().ok().flatten(),
|
||||
};
|
||||
let Some(language) = language else { return false };
|
||||
language.feature_indices().iter().any(|i| marks(i.get()))
|
||||
|| (language.required_feature_index() != 0xFFFF && marks(language.required_feature_index()))
|
||||
}
|
||||
|
||||
pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
shape_inner(bytes, text, false)
|
||||
}
|
||||
|
||||
/// Only used after the PDF adapter has verified legacy Thai PUA glyph mappings.
|
||||
pub(crate) fn shape_legacy_thai(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
shape_inner(bytes, text, true)
|
||||
}
|
||||
|
||||
fn shape_inner(bytes: &[u8], text: &str, legacy_thai: bool) -> Result<ShapedRun, String> {
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err("shape one bounded text row at a time".into());
|
||||
}
|
||||
@@ -30,7 +68,14 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
return Ok(ShapedRun { glyphs: Vec::new(), advance: 0.0 });
|
||||
}
|
||||
let face = FontRef::new(bytes).map_err(|e| e.to_string())?;
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0fff | 0x1780..=0x17ff)) && face.gsub().is_err() {
|
||||
// Only a vowel or tone mark set on its base (or Sara Am, whose Nikhahit is) needs positioning:
|
||||
// digits and bare letters shape the same without it.
|
||||
if !legacy_thai && text.chars().any(|c| matches!(u32::from(c), 0x0e31 | 0x0e33..=0x0e3a | 0x0e47..=0x0e4e)) && !positions_thai_marks(&face) {
|
||||
return Err("the source subset has no Thai mark positioning (GPOS mark/mkmk); select its full font".into());
|
||||
}
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0dff | 0x0e80..=0x0fff | 0x1780..=0x17ff))
|
||||
&& face.gsub().is_err()
|
||||
{
|
||||
return Err("the source subset has no contextual substitution tables; select its full font".into());
|
||||
}
|
||||
let data = ShaperData::new(&face);
|
||||
@@ -56,7 +101,11 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (g, p) in output.glyph_infos().iter().zip(output.glyph_positions()) {
|
||||
if g.glyph_id == 0 {
|
||||
return Err("the full font lacks a required contextual glyph; choose the complete source face".into());
|
||||
let missing = input.get(g.cluster as usize..).and_then(|s| s.chars().next()).unwrap_or('\u{fffd}');
|
||||
return Err(format!(
|
||||
"the full font lacks a required contextual glyph for {missing} (U+{:04X}); choose the complete source face",
|
||||
u32::from(missing)
|
||||
));
|
||||
}
|
||||
let start = g.cluster as usize;
|
||||
let end = starts.get(starts.partition_point(|i| *i <= start)).copied().unwrap_or(input.len());
|
||||
@@ -74,3 +123,130 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
}
|
||||
Ok(ShapedRun { glyphs, advance: x })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Where the GPOS table starts in an sfnt.
|
||||
fn gpos(bytes: &[u8]) -> usize {
|
||||
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
let record = (0..count).map(|i| 12 + i * 16).find(|&r| &bytes[r..r + 4] == b"GPOS").unwrap();
|
||||
u32::from_be_bytes(bytes[record + 8..record + 12].try_into().unwrap()) as usize
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> usize {
|
||||
u16::from_be_bytes([bytes[at], bytes[at + 1]]) as usize
|
||||
}
|
||||
|
||||
/// Where the GPOS ScriptList starts.
|
||||
fn script_list(bytes: &[u8]) -> usize {
|
||||
gpos(bytes) + u16_at(bytes, gpos(bytes) + 4)
|
||||
}
|
||||
|
||||
/// Where script `tag`'s table starts.
|
||||
fn script(bytes: &[u8], tag: &[u8; 4]) -> usize {
|
||||
let list = script_list(bytes);
|
||||
let record = (0..u16_at(bytes, list)).map(|i| list + 2 + i * 6).find(|&r| &bytes[r..r + 4] == tag).unwrap();
|
||||
list + u16_at(bytes, record + 4)
|
||||
}
|
||||
|
||||
/// Retag script `from` as `to`, keeping the records sorted by tag as the shaper's binary
|
||||
/// search needs.
|
||||
fn retag_script(bytes: &mut [u8], from: &[u8; 4], to: &[u8; 4]) {
|
||||
let list = script_list(bytes);
|
||||
let count = u16_at(bytes, list);
|
||||
let mut records: Vec<[u8; 6]> = (0..count).map(|i| bytes[list + 2 + i * 6..list + 8 + i * 6].try_into().unwrap()).collect();
|
||||
records.iter_mut().find(|r| &r[..4] == from).unwrap()[..4].copy_from_slice(to);
|
||||
records.sort_by(|a, b| a[..4].cmp(&b[..4]));
|
||||
for (i, record) in records.iter().enumerate() {
|
||||
bytes[list + 2 + i * 6..list + 8 + i * 6].copy_from_slice(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// A shaper uses a font's `thai` script when it has one: marks the font registers only for
|
||||
/// Latin don't position Thai.
|
||||
#[test]
|
||||
fn thai_layout_counts_only_the_script_the_shaper_selects() {
|
||||
let mut bytes = crate::script::INTER_REGULAR.to_vec();
|
||||
// Inter's DFLT becomes a `thai` script whose default language system has no features;
|
||||
// its latn script keeps `mark` and `mkmk`.
|
||||
retag_script(&mut bytes, b"DFLT", b"thai");
|
||||
let thai = script(&bytes, b"thai");
|
||||
let language = thai + u16_at(&bytes, thai);
|
||||
assert_ne!(language, thai, "a default language system");
|
||||
bytes[language + 2..language + 6].copy_from_slice(&[0xFF, 0xFF, 0, 0]);
|
||||
assert!(!has_thai_layout(&bytes), "the latn script's marks are not the thai script's");
|
||||
}
|
||||
|
||||
/// Given no language, the shaper takes a language system tagged `dflt` before the script's
|
||||
/// default one, in both directions.
|
||||
#[test]
|
||||
fn thai_layout_follows_a_language_system_tagged_dflt() {
|
||||
// Without DFLT the shaper falls back to latn, whose default language system has marks and
|
||||
// is shared by its CAT, MOL and ROM records. Cyrillic's default one has only `kern`.
|
||||
let mut base = crate::script::INTER_REGULAR.to_vec();
|
||||
retag_script(&mut base, b"DFLT", b"zzzz");
|
||||
assert!(has_thai_layout(&base));
|
||||
let latn = script(&base, b"latn");
|
||||
let kern_only = script(&base, b"cyrl") + u16_at(&base, script(&base, b"cyrl"));
|
||||
let kern_only = u16::try_from(kern_only - latn).unwrap().to_be_bytes();
|
||||
let rom = (0..u16_at(&base, latn + 2)).map(|j| latn + 4 + j * 6).find(|&r| &base[r..r + 4] == b"ROM ").unwrap();
|
||||
// ROM becomes `dflt` (still sorted last) and has only kerning: it wins over the default.
|
||||
let mut named_without = base.clone();
|
||||
named_without[rom..rom + 4].copy_from_slice(b"dflt");
|
||||
named_without[rom + 4..rom + 6].copy_from_slice(&kern_only);
|
||||
assert!(!has_thai_layout(&named_without), "the dflt language system has no marks");
|
||||
// The default has only kerning and the `dflt` one keeps the marks: it wins again.
|
||||
let mut default_without = base.clone();
|
||||
default_without[rom..rom + 4].copy_from_slice(b"dflt");
|
||||
default_without[latn..latn + 2].copy_from_slice(&kern_only);
|
||||
assert!(has_thai_layout(&default_without), "the dflt language system has the marks");
|
||||
}
|
||||
|
||||
/// A GPOS table alone is not mark positioning: with its `mark` and `mkmk` features renamed,
|
||||
/// as in a kerning-only subset, a face can't stack Thai vowels and tones.
|
||||
#[test]
|
||||
fn thai_layout_needs_mark_positioning_features() {
|
||||
let inter = crate::script::INTER_REGULAR;
|
||||
assert!(has_thai_layout(inter), "Inter positions marks under its default script");
|
||||
let mut bytes = inter.to_vec();
|
||||
let table = gpos(&bytes);
|
||||
let features = table + u16_at(&bytes, table + 6);
|
||||
let mut renamed = 0;
|
||||
for i in 0..u16_at(&bytes, features) {
|
||||
let tag = features + 2 + i * 6;
|
||||
if matches!(&bytes[tag..tag + 4], b"mark" | b"mkmk") {
|
||||
bytes[tag..tag + 4].copy_from_slice(b"kern");
|
||||
renamed += 1;
|
||||
}
|
||||
}
|
||||
assert!(renamed > 0);
|
||||
assert!(FontRef::new(&bytes).unwrap().gpos().is_ok(), "the GPOS table still parses");
|
||||
assert!(!has_thai_layout(&bytes));
|
||||
let error = shape(&bytes, "กิ่").unwrap_err();
|
||||
assert!(error.contains("mark positioning"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_positioning_does_not_require_optional_gsub() {
|
||||
let Some(face) = crate::document_thai_font() else {
|
||||
eprintln!("skipping thai_positioning_does_not_require_optional_gsub: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
|
||||
return;
|
||||
};
|
||||
let mut bytes = face.bytes.to_vec();
|
||||
// Keep the actual GPOS anchors and outlines while removing optional substitutions.
|
||||
// Table payloads stay at their original offsets; no font fixture is committed.
|
||||
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
let tables: Vec<_> = bytes[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB").map(|t| t.to_vec()).collect();
|
||||
bytes[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
|
||||
for (i, table) in tables.iter().enumerate() {
|
||||
bytes[12 + i * 16..28 + i * 16].copy_from_slice(table);
|
||||
}
|
||||
assert!(FontRef::new(&bytes).unwrap().gsub().is_err());
|
||||
assert!(has_thai_layout(&bytes));
|
||||
let shaped = shape(&bytes, "กิ่ น้ำ").unwrap();
|
||||
assert!(shaped.advance > 0.0);
|
||||
assert!(shaped.glyphs.iter().all(|g| g.position.iter().all(|p| p.is_finite())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,6 +662,10 @@ pub struct DocView {
|
||||
/// Edit text: the lines per page (with the document generation they were read at), and the
|
||||
/// line being edited.
|
||||
pub(crate) edit_lines: HashMap<usize, (u64, Vec<pdfcraft_engine::TextBlock>)>,
|
||||
/// Edit text: a click on grouped artwork with text queued [`Edit::PrepareText`] for its page:
|
||||
/// the page and the point clicked (user space), whose paragraph opens once the page is
|
||||
/// prepared, so one click on grouped text starts editing it.
|
||||
pub(crate) grouped_text_click: Option<(usize, [f64; 2])>,
|
||||
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
|
||||
/// The paragraph editor whose edit was just queued. It opens again with its typing if the
|
||||
/// edit fails, as a refused form field does.
|
||||
@@ -1064,6 +1068,7 @@ impl DocView {
|
||||
organize_range: Default::default(),
|
||||
pending_edit: None,
|
||||
edit_lines: HashMap::new(),
|
||||
grouped_text_click: None,
|
||||
line_editor: None,
|
||||
applied_editor: None,
|
||||
pending_text_open: None,
|
||||
|
||||
@@ -219,7 +219,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
let adding = (tool == QuickTool::AddText).then_some(added.as_slice());
|
||||
let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding };
|
||||
let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view);
|
||||
let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate };
|
||||
let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, doc, info, press, gate };
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
|
||||
text_used || image_used
|
||||
};
|
||||
|
||||
@@ -707,6 +707,16 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
|
||||
None => view.pending_text_open = None,
|
||||
}
|
||||
}
|
||||
// A click on grouped text prepared the page (`image_input`): the paragraph now at the point
|
||||
// clicked opens, and none if the point is on the group's artwork. A preparation that failed
|
||||
// left the page as it was, where no paragraph is at that point. It is taken either way.
|
||||
if let Some((_, [x, y])) = view.grouped_text_click.take_if(|(p, _)| *p == page)
|
||||
&& gate
|
||||
&& active.is_none()
|
||||
&& let Some((block, _)) = grab_at(&boxes, user_to_screen(xf, info, page, x, y), false)
|
||||
{
|
||||
open_editor(cx, lines, &boxes, block, view);
|
||||
}
|
||||
if !gate {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(crate) struct ImageCx<'a> {
|
||||
pub xf: &'a PageXform,
|
||||
pub ptr: &'a PagePointer,
|
||||
pub page: usize,
|
||||
pub doc: &'a pdfcraft_engine::Document,
|
||||
pub info: &'a DocInfo,
|
||||
/// What the press the pages hold is to this page.
|
||||
pub press: PagePress,
|
||||
@@ -268,7 +269,7 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
/// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]).
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool {
|
||||
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
|
||||
let ImageCx { ui, resp, xf, ptr, page, info, press, gate, .. } = *cx;
|
||||
let painter = ui.painter();
|
||||
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
|
||||
let hover_pos = ptr.pos;
|
||||
@@ -686,7 +687,7 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
|
||||
/// pointer was used.
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool {
|
||||
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
|
||||
let ImageCx { ui, resp, xf, ptr, page, doc, info, press, gate } = *cx;
|
||||
let boxes: Vec<Rect> = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect();
|
||||
let painter = ui.painter();
|
||||
let hover_pos = ptr.pos;
|
||||
@@ -977,18 +978,32 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this
|
||||
// page leaves it unselected (where the page's objects can be selected together, the area
|
||||
// selection the press starts has done so already).
|
||||
// A click selects the picture its press was on; its cursor is the hit's. One on grouped
|
||||
// artwork with text prepares the page instead (one undoable step), and the paragraph clicked
|
||||
// opens once it has (`edit_text_ui::page_input`): a drag still moves the group selected. Not
|
||||
// while an edit is queued or a paragraph is open, whose typing that edit would overtake. One
|
||||
// nobody holds on this page leaves the picture unselected (where the page's objects can be
|
||||
// selected together, the area selection the press starts has done so already).
|
||||
let owner = match press {
|
||||
PagePress::Mine(Owner::Picture { index, .. }) => Some(index),
|
||||
_ => None,
|
||||
};
|
||||
if gate && resp.clicked() {
|
||||
match owner {
|
||||
Some(index) => view.image_selection = Some(ImageSelection::at(page, index)),
|
||||
None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None,
|
||||
None => {}
|
||||
// Where grouped artwork with text was clicked; the page is read for it once, on the click.
|
||||
let grouped_text = owner
|
||||
.filter(|i| images.get(*i).is_some_and(|im| im.is_form) && view.pending_edit.is_none() && view.line_editor.is_none())
|
||||
.zip(ptr.released.or(ptr.origin))
|
||||
.filter(|(i, _)| doc.grouped_text_at(page, *i));
|
||||
match (owner, grouped_text) {
|
||||
(_, Some((_, at))) => {
|
||||
let (x, y) = screen_to_user(xf, info, page, at);
|
||||
view.image_selection = None;
|
||||
view.pending_edit = Some(Edit::PrepareText { page });
|
||||
view.grouped_text_click = Some((page, [x, y]));
|
||||
}
|
||||
(Some(index), None) => view.image_selection = Some(ImageSelection::at(page, index)),
|
||||
(None, _) if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None,
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
owner.is_some()
|
||||
|
||||
@@ -68,7 +68,9 @@ pub(crate) fn editor_fill(text: Color32) -> Color32 {
|
||||
|
||||
/// One TextEdit lays out all source runs; its caret and selection span the whole region.
|
||||
pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap<String, String> {
|
||||
let programs = doc.text_editor_fonts(page);
|
||||
// Bundled replacement selectors are menu choices, never source resource names. Installing
|
||||
// an unused choice would make even a standard-font editor wait a frame for its font atlas.
|
||||
let programs: Vec<_> = doc.text_editor_fonts(page).into_iter().filter(|font| !font.choice_only).collect();
|
||||
let mut definitions = ctx.fonts(|f| f.definitions().clone());
|
||||
// Only the current editor's document fonts live in the atlas. Never accumulate the
|
||||
// programs of previously opened files or add them to the interface's default families.
|
||||
|
||||
@@ -10464,29 +10464,18 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() {
|
||||
#[test]
|
||||
fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
|
||||
let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET";
|
||||
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
|
||||
stream("<<", b"q /Fm1 Do Q"),
|
||||
stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table),
|
||||
pdf_stream("<<", b"q /Fm1 Do Q"),
|
||||
pdf_stream(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>",
|
||||
table,
|
||||
),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let pdf = pdf_of_objects(&objs);
|
||||
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
@@ -10521,6 +10510,89 @@ fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
|
||||
assert_eq!(blocks(&h), 0, "undo puts the form back");
|
||||
}
|
||||
|
||||
/// A stream object `dict` (its opening `<<` and entries, without `/Length` or `>>`) holding `data`.
|
||||
fn pdf_stream(dict: &str, data: &[u8]) -> Vec<u8> {
|
||||
[format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat()
|
||||
}
|
||||
|
||||
/// A PDF of `objs`, numbered from 1, with the first as its catalog.
|
||||
fn pdf_of_objects(objs: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// Grouped text is not prepared while the pages are only looked at: entering Edit text and
|
||||
/// scrolling change nothing. One click on the group's text prepares that page, as one undoable
|
||||
/// step, and opens the paragraph clicked for typing. A click on a grouped logo selects it, as a
|
||||
/// click on any picture does, and changes nothing; a drag from the text group prepares nothing.
|
||||
#[test]
|
||||
fn one_click_on_grouped_text_opens_it_for_editing() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Body 5 0 R /Logo 7 0 R >> >> >>".to_vec(),
|
||||
pdf_stream("<<", b"q /Body Do Q q 1 0 0 1 300 20 cm /Logo Do Q"),
|
||||
pdf_stream(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 280 300] /Resources << /Font << /F1 6 0 R >> >>",
|
||||
b"BT /F1 24 Tf 1 0 0 1 40 200 Tm (Grouped words) Tj ET",
|
||||
),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
pdf_stream("<< /Type /XObject /Subtype /Form /BBox [0 0 60 60] /Resources << >>", b"0 0 1 rg 0 0 60 60 re f"),
|
||||
];
|
||||
let pdf = pdf_of_objects(&objs);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("grouped.pdf", None, pdf.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
let doc = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let d = h.state().session.get(id).unwrap();
|
||||
(d.text_blocks(0).len(), d.page_images(0).len(), d.can_undo().map(str::to_string))
|
||||
};
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(6);
|
||||
assert_eq!(doc(&h), (0, 2, None), "nothing is prepared while the page is only shown");
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
// User space (x, y) on the page, which is 400 by 300 points and upright.
|
||||
let at = |x: f32, y: f32| r.left_top() + egui::vec2(r.width() * x / 400.0, r.height() * (300.0 - y) / 300.0);
|
||||
|
||||
click_at(&mut h, at(330.0, 50.0));
|
||||
let logo = h.state().session.get(id).unwrap().page_images(0).iter().position(|i| i.name == "Logo");
|
||||
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)), logo.map(|i| (0, i)), "the logo is selected");
|
||||
assert_eq!(doc(&h), (0, 2, None), "a click on artwork without text changes nothing");
|
||||
assert!(h.state().views[0].line_editor.is_none());
|
||||
|
||||
drag(&mut h, at(60.0, 208.0), at(120.0, 150.0));
|
||||
assert_eq!(doc(&h).0, 0, "a drag on the text group prepares nothing");
|
||||
|
||||
click_at(&mut h, at(60.0, 208.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(doc(&h), (1, 1, Some("Prepare grouped text".into())), "the page's grouped text is prepared, in one step");
|
||||
let editing = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone());
|
||||
assert_eq!(editing.as_deref(), Some("Grouped words"), "the paragraph clicked opens for typing");
|
||||
|
||||
assert!(h.state_mut().execute("edit.undo"));
|
||||
h.run_steps(3);
|
||||
let (blocks, images, _) = doc(&h);
|
||||
assert_eq!((blocks, images), (0, 2), "undo puts the group back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outward_image_grip_drags_grow_on_rotated_pages() {
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Editing existing text and source subsets
|
||||
|
||||
In **Edit text and images**, text drawn inside a Form XObject (grouped artwork) is edited
|
||||
with one click: clicking its text prepares that page in an undoable **Prepare grouped text**
|
||||
step and opens the paragraph clicked for typing. Nothing is prepared while pages are only
|
||||
shown or scrolled. A drag still moves the selected group as a whole; a click on grouped
|
||||
artwork that preparation wouldn't open (a logo, a refused group) selects it as before, and
|
||||
changes nothing. To select a text group for moving, right-click it. While a paragraph is open
|
||||
or another edit is waiting, the click selects the group instead, as before; the next click
|
||||
prepares it. The group's original operators, clip, placement and resources become page
|
||||
content; shared forms on other pages stay unchanged. Ungroup (the artwork's right-click menu)
|
||||
draws one placement in place with the same code. Headless callers use `text_prepare`, then
|
||||
list `text_paragraphs` and call `text_edit`.
|
||||
This edits existing glyph operators; it adds neither a cover rectangle nor an Add Text box.
|
||||
Preparation refuses knockout/blended groups, groups that blend in a colour space other than
|
||||
the page group's (DeviceRGB on a page that names none), forms whose default
|
||||
colour spaces differ from the page's, tagged forms, forms drawn inside text objects or
|
||||
unfinished paths, groups whose nested content can't be checked and unreadable content. Forms
|
||||
that draw only images or vector artwork are left grouped. Decoding is capped at 16 MiB, 64
|
||||
placements and eight expansion levels per page; text grouped deeper is reported. Unsupported
|
||||
groups remain intact: `text_prepare` lists each with its reason, and the repair log records
|
||||
them when the page is prepared. A narrowly fingerprinted legacy THSarabunPSK exporter map can
|
||||
also be repaired in copied page fonts, only when every glyph the subset kept advances as its
|
||||
assumed Thai character does; `text_prepare` repairs it also for text that was never grouped,
|
||||
and the repair log records both preparation and map recovery.
|
||||
|
||||
Click a text region in **Edit a PDF → Edit text and images**. One editor contains its
|
||||
source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory;
|
||||
source font programs, colour, size and baseline anchors stay intact. A textless preview
|
||||
@@ -44,6 +68,44 @@ this works only when that build input actually contains the full face. This chan
|
||||
add a font binary or change the release font pin. Fonts shared by releases belong in
|
||||
`storytold/craft-fonts` under the repository's asset rules.
|
||||
|
||||
**Keep source fonts** retains the existing Thai family, weight and point size. For legacy
|
||||
THSarabunPSK subsets, PdfCraft reconstructs an editor-only Unicode cmap from the PDF's
|
||||
Unicode and CID-to-glyph maps. HarfRust uses the retained Thai positional glyphs, and the
|
||||
saved text refers to the original PDF font resources. Several subsets of the same exact
|
||||
face can supply Thai and punctuation glyphs, including a Thai run whose clusters are split
|
||||
across subsets, and a subset whose glyph for a character is empty is skipped; Regular and
|
||||
Bold stay separate. Source font
|
||||
programs are never modified or replaced. Text settings, including character/word spacing,
|
||||
horizontal scale, rise, color and paint mode, remain in the PDF text state.
|
||||
|
||||
A source subset that lacks a needed contextual glyph produces an error asking for the
|
||||
complete original face or an explicit replacement. Cross-family replacement requires an
|
||||
explicit choice. The document-font menu offers **Noto Sans Thai**: pass its `text_fonts`
|
||||
resource (normally `builtin:thai`) as `text_edit.font_resource` or a range's `font_resource`. This replacement
|
||||
requires the optional craft-fonts Thai input and currently uses Regular. A matching Noto
|
||||
subset can still be completed by that same bundled family when it is Regular and upright; a
|
||||
subset its name or descriptor marks bold or italic needs an explicit replacement.
|
||||
|
||||
The bundled variable Thai face is written as bounded Type 3 glyph outlines, with shaped
|
||||
vowel/tone positions and logical Unicode text, rather than embedded as a static font.
|
||||
Saved paragraphs can be reopened and edited again without installing a font. Modern Thai
|
||||
shaping of vowels and tone marks uses the source's GPOS mark positioning (a `mark` or `mkmk` feature in the script and language system the shaper
|
||||
selects for Thai, not just a GPOS table); GSUB substitution is optional, as specified by
|
||||
[the OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai).
|
||||
Verified legacy Thai PUA fonts use positional substitutions instead of requiring GPOS; their
|
||||
alternates are used only when each kept one has a mark's zero advance (or, for the two
|
||||
consonant forms, an advance) and, where the PDF's own map names its character, the character
|
||||
that alternate stands for. A THSarabunPSK program with its own Thai mark
|
||||
positioning, such as a complete face loaded with Load font, is shaped by that layout instead,
|
||||
still in its own codes. One without it takes the legacy path only when every Thai glyph it keeps
|
||||
is where the legacy glyph order puts it, and is otherwise shaped as an ordinary source font. A
|
||||
subset needs only the glyphs its new text uses. When no subset can show the new text in its
|
||||
existing codes, a complete program of the same face and style (one added with Load font, CFF
|
||||
included) shapes the whole span with its own layout, Latin rows too, rather than the edit being
|
||||
refused; a Regular program never completes text the source marks bold or italic.
|
||||
Mixed contextual style runs and dictionary word segmentation remain limited. No font binary
|
||||
or release pin changes are needed.
|
||||
|
||||
When mixed-style text needs outline fallback, characters using the same actual fallback face
|
||||
share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same
|
||||
font and source style are written as one run. Latin characters that the source font can encode
|
||||
|
||||
+4
-1
@@ -1,5 +1,7 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
|
||||
@@ -241,7 +243,7 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Thai editing (#2964, #3185): supported Form text is editable; legacy Thai retains source fonts and text settings. Cross-family replacement is explicit; unsafe groups and mixed contextual styles remain partial. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
@@ -317,6 +319,7 @@ Estimates unchanged.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. |
|
||||
| 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. |
|
||||
| 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier |
|
||||
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user