Merge remote-tracking branch 'upstream/main' into lane3-6504

# Conflicts:
#	crates/annot/src/draw.rs
This commit is contained in:
mtlcl410 committed 2026-11-09 03:24:27 +08:00
commit cb3d7e2997
68 files changed
+3307 -451

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
### 1.2 Only open, provable licences
Every asset must be one of:
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera.
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0, Bitstream-Vera or Unicode-3.0 (Unicode, Inc. data such as ICU4X's line-breaking dictionaries; already allowed for code in `deny.toml`).
- **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0.
- **Public domain.**
- **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`).
+2 -1
View File
@@ -267,7 +267,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
## Compiled in through dependencies (7)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
@@ -277,6 +277,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one |
## Downloaded at build time (22)
+12
View File
@@ -2263,6 +2263,18 @@ adobe_data = true
usage = "Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1"
sha256 = "2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41"
[[bundled]]
crate = "icu_segmenter_data"
version = "2.3.0"
path = "data/segmenter_dictionary_extended_v1.rs.data"
title = "Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese)"
author = "The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.)"
source = "https://github.com/unicode-org/icu4x (via icu_segmenter_data)"
licence = "Unicode-3.0"
kind = "data"
usage = "Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one"
sha256 = "cad3821ca44fdb88ed856ff3336444aa200afe7b9d19e34ac28ae6a4459d4c6d"
[[fetched]]
file = "PlayfairDisplay[wght].ttf"
url = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf"
Generated
+23
View File
@@ -3133,6 +3133,28 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -4400,6 +4422,7 @@ name = "pdfcraft-edit"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"icu_segmenter",
"kurbo 0.13.1",
"pdfcraft-annot",
"pdfcraft-content",
+14 -1
View File
@@ -16,7 +16,7 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
[[document]]
source = "NOTICE"
destination = "NOTICE"
sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314"
sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb"
[[document]]
source = "assets/app-icon/LICENSE-document.txt"
@@ -60,6 +60,13 @@ destination = "licenses/CMap-LICENSE.txt"
sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29"
origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt"
[[document]]
source = "licenses/ICU4X-LICENSE.txt"
licences = ["Unicode-3.0"]
destination = "licenses/ICU4X-LICENSE.txt"
sha256 = "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2"
origin = "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate#LICENSE"
[[document]]
source = "licenses/egui-Hack-Regular.txt"
licences = ["MIT", "Bitstream-Vera"]
@@ -164,3 +171,9 @@ crate = "hayro-interpret"
path = "src/font/generated/metrics.rs"
licence = "MIT OR Apache-2.0"
documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"]
[[bundled]]
crate = "icu_segmenter_data"
path = "data/segmenter_dictionary_extended_v1.rs.data"
licence = "Unicode-3.0"
documents = ["licenses/ICU4X-LICENSE.txt"]
+29
View File
@@ -36,6 +36,9 @@ list (author, source, license) is in ATTRIBUTION.md.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
- ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer
and Burmese word dictionaries are compiled in for wrapping Thai text; notice below, full licence in
licenses/ICU4X-LICENSE.txt.
Required notices:
@@ -48,6 +51,32 @@ Required notices:
(https://github.com/adobe-type-tools/cmap-resources/blob/master/LICENSE.txt). Neither the name of
Adobe nor the names of its contributors are used to endorse or promote this product.
ICU4X segmenter data (bundled by icu_segmenter_data 2.3.0; word lists, not visual assets):
Copyright © 2020-2024 Unicode, Inc. Portions adapted from ICU4C and/or ICU4J:
ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any
associated documentation (the "Data Files") or software and any associated documentation (the
"Software") to deal in the Data Files or Software without restriction, including without
limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the
Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to
do so, provided that either (a) this copyright and permission notice appear with all copies of
the Data Files or Software, or (b) this copyright and permission notice appear in associated
Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM,
OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF
USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall not be used in
advertising or otherwise to promote the sale, use or other dealings in these Data Files or
Software without prior written authorization of the copyright holder.
Showcase PDF (built by `cargo xtask demo-pdf`, not committed): fonts are downloaded at build time from
google/fonts at a pinned commit, verified by SHA-256, all under the SIL Open Font License 1.1 — see the
"Downloaded at build time" table in ATTRIBUTION.md.
+1
View File
@@ -249,6 +249,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
- **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged.
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
+5 -1
View File
@@ -12,7 +12,11 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as
ISO 32000-2 Table 178 places them, the caption in black Helvetica 10.
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
line breaking by Helvetica's standard-14 metrics. Thai in a text box, callout or Fill & Sign
text is shaped and drawn from the craft-fonts Thai face with Add text's writer
(`pdfcraft_fonts::shaped_text`: Type 3 glyphs with a ToUnicode map), the box sized to the ink
of its stacked marks and descenders; a build without a Thai face refuses it
(`AnnotError::Undrawable`) instead of drawing `?`. Other text it can't
draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic
stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
+53 -15
View File
@@ -9,12 +9,39 @@
//! (`draw::with_direct_values`).
//!
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3
//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses
//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances.
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
use pdfcraft_fonts::shaped_text;
use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with};
/// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai
/// face that text boxes draw it with.
pub fn text_width(text: &str, size: f64) -> f64 {
shaped_text::thai_width(text, size, helvetica_width)
}
/// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter.
pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
wrap_with(text, size, width, text_width)
}
/// How far a text box's `lines` reach, in em: above the first baseline (0.9 of the 1.2 em line)
/// and below the last (0.3), or further where Thai stacked marks or descenders reach, so the box
/// is sized to their ink and doesn't clip them.
fn reach(lines: &[String]) -> [f64; 2] {
let ink = |line: Option<&String>| line.and_then(|l| shaped_text::thai_reach(l));
[ink(lines.first()).map_or(0.9, |r| r[1].max(0.9)), ink(lines.last()).map_or(0.3, |r| (-r[0]).max(0.3))]
}
/// The height a text box's `lines` take at `size`, inside its padding: 1.2 em a line, and more
/// for Thai that reaches further ([`reach`]).
pub fn text_lines_height(lines: &[String], size: f64) -> f64 {
let [up, down] = reach(lines);
lines.len().max(1) as f64 * size * 1.2 + (up - 0.9 + down - 0.3) * size
}
use crate::{Document, NOTE_SIZE, Rgb, n};
@@ -394,13 +421,15 @@ pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> {
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a
/// text of a text box, callout or Fill & Sign text (whose Thai the Thai face draws, where this
/// build has one), a line's caption, a measurement's label, a
/// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
/// it can be drawn.
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
let (what, text) = match d.name(b"Subtype")? {
b"FreeText" => ("the comment's text", text(b"Contents")?),
// A text box draws Thai too.
b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)),
b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?),
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
@@ -413,6 +442,11 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
pub(crate) fn helvetica_resource() -> Object {
Object::Dict(helvetica_fonts())
}
/// [`helvetica_resource`]'s dictionary.
fn helvetica_fonts() -> Dict {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
@@ -420,7 +454,7 @@ pub(crate) fn helvetica_resource() -> Object {
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
Object::Dict(fonts)
fonts
}
/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for
@@ -1108,27 +1142,31 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
));
let mut out = c.into_bytes();
let lines = wrap(&text, size, width);
let mut y = rect[3] - pad - size * 0.9;
let mut y = rect[3] - pad - size * reach(&lines)[0];
if round {
y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0);
y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0);
}
for line in lines {
// Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose
// streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as
// `?`, which `undrawable_text` reports), in Helvetica.
let mut fonts = helvetica_fonts();
let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts);
let mut current = None;
for pieces in &pieces {
if y < rect[1] - size {
break;
}
let lw = text_width(&line, size);
let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width);
let x = match q {
1 => rect[0] + pad + (width - lw) / 2.0,
2 => rect[2] - pad - lw,
_ => rect[0] + pad,
};
out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
out.extend(literal(&win_ansi(&line)));
out.extend_from_slice(b" Tj\n");
shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current);
y -= size * 1.2;
}
out.extend_from_slice(b"ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
res.set(b"Font".to_vec(), Object::Dict(fonts));
let mut stream = form(full, &out, res);
if let Some(m) = matrix {
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
+27 -1
View File
@@ -1,6 +1,6 @@
use super::draw::draw_appearance;
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box};
use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box};
use super::types::{
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
};
@@ -398,6 +398,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
}
let rect = fit_thai_ink(rect, turn, &new.contents, size, 2.0 + appearance::border_width(&d));
// An oval or a circle goes around the text box, its text wrapped as displayed:
// after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page.
shape_text_box(&mut d, rect, style.border_shape, style.width);
@@ -417,6 +418,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
{
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
}
refuse_undrawable_thai(d.name(b"Subtype") == Some(b"FreeText"), &new.contents)?;
let r = doc.add(Object::Dict(d.clone()));
draw_appearance(doc, r)?;
let mut list = annots(doc, page);
@@ -427,6 +429,30 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
Ok(index)
}
/// Text box `tb` (user space; its text upright on a page turned by `turn`), grown down as displayed
/// until the ink of `text`'s Thai (stacked marks above its first line, descenders below its last)
/// fits inside the padding `pad`, as the appearance lays it out at `size`. Other text, and a box
/// tall enough already, keep the box they were given.
fn fit_thai_ink(tb: [f64; 4], turn: i64, text: &str, size: f64, pad: f64) -> [f64; 4] {
if !text.chars().any(pdfcraft_fonts::shaped_text::is_thai) {
return tb;
}
let [x0, y0, x1, y1] = normalize(tb);
let (w, h) = if matches!(turn, 90 | 270) { (y1 - y0, x1 - x0) } else { (x1 - x0, y1 - y0) };
let need = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size) + 2.0 * pad + 2.0;
let by = need - h;
if !(by.is_finite() && by > 0.0) {
return tb;
}
// Displayed down is user -y unturned, +x on a page turned 90°, +y at 180° and -x at 270°.
match turn {
90 => [x0, y0, x1 + by, y1],
180 => [x0, y0, x1, y1 + by],
270 => [x0 - by, y0, x1, y1],
_ => [x0, y0 - by, x1, y1],
}
}
fn validate_style(style: &Style) -> Result<(), AnnotError> {
if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() {
return Err(AnnotError::Invalid("invalid style".into()));
+3 -1
View File
@@ -230,7 +230,9 @@ impl Built {
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, built: Built) -> Result<(), AnnotError> {
// `missing`: characters the standard fonts can't draw came out as `?`; the annotation is
// marked with the first.
let Built { stream, missing, rect } = built;
let Built { mut stream, missing, rect } = built;
// A text box's Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict);
let ap = doc.add(Object::Stream(stream));
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
// appearances would show the previous look on press or hover, so they go with the old /N.
+13 -3
View File
@@ -25,6 +25,15 @@ pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotE
Ok(())
}
/// Thai typed into a text box (`free_text`) that this build can't draw (it has no Thai face): refused
/// with what to do, rather than drawn as `?`.
pub(crate) fn refuse_undrawable_thai(free_text: bool, text: &str) -> Result<(), AnnotError> {
match free_text.then(|| pdfcraft_fonts::shaped_text::thai_refusal(text)).flatten() {
Some(why) => Err(AnnotError::Undrawable(why)),
None => Ok(()),
}
}
/// Format a number for content streams and DA strings.
pub(crate) fn n(v: f64) -> String {
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
@@ -165,6 +174,7 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
let (_, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
let free_text = d.name(b"Subtype") == Some(b"FreeText");
refuse_undrawable_thai(free_text, text)?;
let snug = free_text && is_snug(&d);
doc.update_dict(r, |d| {
d.set(b"Contents".to_vec(), PdfString::text(text));
@@ -224,8 +234,8 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
};
let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64;
Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top])
let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size);
Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top])
}
/// Reply to a comment; returns the reply's index in the page's `/Annots`.
@@ -459,7 +469,7 @@ fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] {
let (_, size) = appearance::parse_da(d);
let lines = appearance::wrap(&text, size, wrap.max(1.0));
let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
[widest, lines.len().max(1) as f64 * size * 1.2]
[widest, appearance::text_lines_height(&lines, size)]
}
/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by
+63
View File
@@ -2970,6 +2970,69 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
check(&reopen(&doc));
}
/// The `y` of every `1 0 0 1 x y Tm` in `content`: where each piece of text sits.
fn baselines(content: &str) -> Vec<f64> {
content.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect()
}
/// Thai typed into Fill & Sign text or a text box is shaped and drawn from the Thai face, as Add
/// text draws it: Type 3 glyphs (their streams objects of their own) with a ToUnicode map, never
/// `?`. The box grows to hold the ink of its stacked marks and descenders, which reach past the
/// 1.2 em lines boxes are sized by. A build without a Thai face refuses the text, saying why.
#[test]
fn thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face() {
let mut doc = fixture();
// Each line has the face's highest stack (น้ำ) and deepest descender (ฎู).
let thai = "น้ำฎู บุญ\nปั้นน้ำฎู";
let item = |shape: Shape| NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: thai.into(), author: "Ada".into() };
let shapes = [
Shape::Typewriter { rect: [100.0, 700.0, 300.0, 716.0], font_size: 24.0 },
Shape::TextBox { rect: [100.0, 400.0, 400.0, 430.0], font_size: 30.0 },
];
if pdfcraft_fonts::document_thai_font().is_none() {
for shape in shapes {
let err = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err:?}");
}
assert_eq!(list(&doc, 0).len(), 1, "nothing was added");
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 }), &meta("b")).unwrap();
assert!(matches!(set_contents(&mut doc, 0, t, thai, &meta("")), Err(AnnotError::Undrawable(_))));
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "hello");
return;
}
let reach = pdfcraft_fonts::shaped_text::thai_reach;
let (lo, hi) = (reach("ฎู").unwrap()[0], reach("น้ำ").unwrap()[1]);
for (shape, size) in shapes.into_iter().zip([24.0, 30.0]) {
let t = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[t];
assert_eq!((text(d, b"Contents").as_str(), pdfcraft_fonts::undrawn_mark(d)), (thai, None));
let content = ap_content(&doc, d);
assert!(!content.contains('?') && content.contains("/PCTh1 "), "{content}");
// The glyphs and the text they stand for are objects of the saved file.
let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).unwrap();
let ap = doc.get(n);
let fonts = ap.as_dict().and_then(|s| s.get(b"Resources")).and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict());
let font = fonts.and_then(|f| f.get(b"PCTh1")).and_then(|f| f.as_dict()).unwrap().clone();
assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..]));
let cmap = doc.get(font.reference(b"ToUnicode").unwrap());
let Object::Stream(cmap) = &*cmap else { panic!("ToUnicode is a stream object") };
let cmap = String::from_utf8_lossy(&cmap.decoded().unwrap()).into_owned();
assert!(cmap.contains("<0E190E490E33>"), "น้ำ copies as typed: {cmap}");
let procs = font.get(b"CharProcs").and_then(|p| p.as_dict()).unwrap();
assert!(procs.iter().all(|(_, g)| matches!(g, Object::Ref(_))), "glyph programs are stream objects");
// Every line's ink lies inside the box: the appearance's BBox and its clip.
let (r, ys) = (rect(d), baselines(&content));
let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min));
assert!(top + hi * size <= r[3] + 1e-3 && bottom + lo * size >= r[1] - 1e-3, "{ys:?} at {size} in {r:?}");
}
// New text refits the box to its ink too: the stack on its first line, the descender on its last.
set_contents(&mut doc, 0, 1, "น้ำ\nฎู", &meta("")).unwrap();
let d = &list(&doc, 0)[1];
let (r, ys) = (rect(d), baselines(&ap_content(&doc, d)));
assert!(ys[0] + hi * 24.0 <= r[3] + 1e-3 && ys[ys.len() - 1] + lo * 24.0 >= r[1] - 1e-3, "{ys:?} in {r:?}");
}
const QUAD: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0];
fn reals(v: &[f64]) -> Vec<Object> {
+4 -1
View File
@@ -146,7 +146,10 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items
with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}`
moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes
and fonts. A refusal changes nothing; success is one undo step. See
and fonts. A refusal changes nothing; success is one undo step. `all: true` takes every object on the
page instead, as ⌘A does. `object_copy {doc, page, objects | all, cut?}` copies them with every
resource they use and `object_paste {doc, page}` pastes them as page content that draws the same,
into the same or another open document, stepped as the app steps a paste. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Accessibility review record
+64 -11
View File
@@ -354,14 +354,32 @@ impl Automation {
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
/// The references `objects` names (from object_list), or with `all` every object on `page`,
/// as ⌘A selects them in Edit a PDF; refused when `generation` is not the document's.
fn object_targets(&self, a: &Args, page: usize) -> Result<Vec<pdfcraft_engine::ObjectTarget>> {
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
let page = self.page(a)?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again"));
}
}
if a.opt_bool("all")?.unwrap_or(false) {
if a.get("objects").is_some() {
return Err(bad("give objects or all, not both"));
}
let all: Vec<_> = doc.editable_objects(page).map_err(failed)?.into_iter().map(|o| o.target).collect();
if all.is_empty() || all.len() > MAX_MOVE_OBJECTS {
return Err(failed(format!("page {} has {} objects; select 1–1000", page + 1, all.len())));
}
return Ok(all);
}
let rows = a
.get("objects")
.and_then(Value::as_array)
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list (or pass all: true)"))?;
let mut targets = Vec::with_capacity(rows.len());
for row in rows {
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
@@ -382,6 +400,12 @@ impl Automation {
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
targets.push(ObjectTarget { kind, index });
}
Ok(targets)
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let delta = a
.get("offset")
.and_then(Value::as_array)
@@ -389,17 +413,46 @@ impl Automation {
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again before moving"));
}
}
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
let offset = self.doc(a)?.object_move_offset(page, delta).map_err(failed)?;
let count = targets.len();
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
result["moved"] = json!(count);
Ok(result)
}
/// Edit ▸ Copy (or with `cut`, Cut) of objects selected together, kept for object_paste into
/// any open document. A cut then deletes them as one undo step.
pub(crate) fn object_copy(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let cut = a.opt_bool("cut")?.unwrap_or(false);
let doc = self.doc(a)?;
if cut && !doc.allows_modification() {
return Err(failed(pdfcraft_engine::EditError::NotPermitted("changes")));
}
let clip = doc.copy_objects(page, &targets).map_err(failed)?;
let out = json!({ "copied": clip.objects().len(), "text": clip.text() });
self.objects_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
if !cut {
return Ok(out);
}
let delete = Edit::delete_objects(page, targets).ok_or_else(|| failed("nothing to cut"))?;
let mut result = self.apply(a, delete)?;
result["copied"] = out["copied"].clone();
result["text"] = out["text"].clone();
Ok(result)
}
/// Edit ▸ Paste of the objects object_copy copied: onto `page`, where they were, or on the
/// page they came from (and on each further paste onto a page) 18 points down and right.
pub(crate) fn object_paste(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let mut clipboard = self.objects_clipboard.take().ok_or_else(|| failed("copy objects first (object_copy)"))?;
let count = clipboard.clip().objects().len();
let edit = self.doc(a).and_then(|doc| clipboard.paste(doc, page).map_err(failed));
self.objects_clipboard = Some(clipboard);
let mut result = self.apply(a, edit?)?;
result["pasted"] = json!(count);
Ok(result)
}
}
+2
View File
@@ -738,6 +738,8 @@ impl Automation {
"links_remove" => self.links_remove(&a)?,
"object_list" => self.object_list(&a)?,
"object_move" => self.object_move(&a)?,
"object_copy" => self.object_copy(&a)?,
"object_paste" => self.object_paste(&a)?,
"content_list" => self.content_list(&a)?,
"page_add_text" => self.page_add_text(&a)?,
"page_add_image" => self.page_add_image(&a)?,
+3
View File
@@ -101,6 +101,8 @@ pub struct Automation {
/// Review sessions held for `ocr_words` (a guard and the pages read), by document.
ocr_reviews: HashMap<DocId, OcrReview>,
next_ocr_review: u64,
/// What object_copy copied, for object_paste into any open document.
objects_clipboard: Option<pdfcraft_engine::ObjectClipboard>,
}
impl Default for Automation {
@@ -121,6 +123,7 @@ impl Automation {
evicted: HashSet::new(),
ocr_reviews: HashMap::new(),
next_ocr_review: 0,
objects_clipboard: None,
}
}
+17 -7
View File
@@ -204,12 +204,22 @@ pub fn tools() -> Vec<ToolDef> {
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list (or with all: true every object on the page) by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
"offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])),
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","offset"])),
t("object_copy", "Copy or cut objects together", "Edit ▸ Copy (cut: Cut) of 1–1000 mixed references from object_list, or with all: true every object on the page, as ⌘A then ⌘C select and copy them in Edit a PDF. The copy keeps their content operators and every resource they use (fonts, images, ExtGStates, colour spaces, patterns, shadings) and is kept for object_paste into any open document, also after this one changes or closes. cut then deletes them as one undo step. Refused when the document doesn't allow copying (cut: or changes), or when text used as a clipping path is drawn before them. Returns the count copied and their text.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
"cut":{"type":"boolean","description":"Delete them after copying (one undo step)."},
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page"])),
t("object_paste", "Paste copied objects", "Edit ▸ Paste of what object_copy copied, onto page of doc as page content that draws the same: where it was, or on the page it came from 18 points down and to the right, and 18 more for each further paste onto the same page; centred when that would leave it off the page. Resources join the page's under names that don't clash; into another document the fonts, images and other objects they use are copied (each once, sharing what is there; refused past 20,000 objects or 256 MB). Pasted content is untagged and in no layer. One undo step.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.")
.ro()
.cmd("file.word_count")
@@ -483,7 +493,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
@@ -854,7 +864,7 @@ pub fn tools() -> Vec<ToolDef> {
"Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \
note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Thai in a text box or callout is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Other text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
)
.with(schema(
json!({
@@ -1125,7 +1135,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_header_footer",
"Add header and footer",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> <<dd.mm.yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> <<dd.mm.yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier; Thai is drawn from PdfCraft's built-in Thai face beside it (a build without one refuses it). replace: true swaps out existing ones (Update). Undoable.",
)
.cmd("edit.header_footer")
.with(schema(
@@ -1144,7 +1154,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given (Thai drawn from PdfCraft's built-in Thai face; a build without one refuses it), on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")
.cmd("edit.watermark")
.with(schema(
json!({
@@ -1479,7 +1489,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed Thai is drawn from PdfCraft's built-in Thai face (a build without one refuses it); other typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
+113
View File
@@ -9445,6 +9445,50 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() {
assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ"));
}
/// Thai typed into Fill & Sign text, a text box, a form field, a footer and a watermark (#2354) is
/// drawn from PdfCraft's Thai face, as Add text draws it: no tool warns of `?`, and after a save
/// each one's text is found as typed and the page renders. A build without a Thai face refuses
/// the text in each, saying what to do, rather than writing `?????`.
#[test]
fn thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found() {
let dir = workdir("thai-everywhere");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
let thai = "บุญฤทธิ์ น้ำ ปั้น";
let field = ok(&mut a, "form_add_field", json!({"doc": doc, "page": 1, "type": "text", "rect": [20, 120, 220, 142]}))["field"]
.as_str()
.unwrap()
.to_string();
let calls = [
("fill_sign_add", json!({"doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": thai, "font_size": 14})),
("comment_add", json!({"doc": doc, "page": 1, "type": "textbox", "rect": [20, 160, 200, 190], "contents": thai})),
("form_fill", json!({"doc": doc, "values": {field.as_str(): thai}})),
("doc_header_footer", json!({"doc": doc, "pages": [1], "footer_center": format!("{thai} <<1>>")})),
("doc_watermark", json!({"doc": doc, "pages": [1], "text": thai})),
];
if pdfcraft_fonts::document_thai_font().is_none() {
for (tool, args) in calls {
let err = a.call(tool, &args).unwrap_err().to_string();
assert!(err.contains("Thai") && err.contains("CRAFT_FONTS_DIR"), "{tool}: {err}");
}
assert_eq!(ok(&mut a, "comment_list", json!({"doc": doc}))["comments"].as_array().map(Vec::len), Some(0));
return;
}
for (tool, args) in calls {
let r = ok(&mut a, tool, args);
assert!(r.get("warnings").is_none(), "{tool}: {r}");
}
ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai-marks.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path": "thai-marks.pdf"}))["doc"].as_u64().unwrap();
let found = ok(&mut a, "text_find", json!({"doc": reopened, "query": "บุญฤทธิ์"}));
assert_eq!(found["count"], 5, "{found}");
let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 72})).unwrap();
assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, .. } if !data.is_empty())));
// Flattened, every one of them is page text, as typed.
ok(&mut a, "doc_flatten", json!({"doc": reopened}));
assert_eq!(page_text(&mut a, reopened)[0].matches(thai).count(), 5, "{:?}", page_text(&mut a, reopened));
}
#[test]
fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
let dir = workdir("hebrew-content");
@@ -9503,6 +9547,75 @@ fn mixed_objects_move_through_tools_with_generation_and_one_undo() {
assert!(page_text(&mut a, reopened)[0].contains("Added label"));
}
/// Edit a PDF's ⌘A, ⌘C / ⌘X and ⌘V headlessly: every object of a page copied, pasted into
/// another document and onto its own page (stepped down and right), and cut as one undo step.
#[test]
fn objects_copy_cut_and_paste_through_tools_within_and_across_documents() {
let dir = workdir("object-copy-paste");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
let other = ok(&mut a, "doc_open", json!({"path":"b.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Added label","rect":[20,20,130,45]}));
assert!(matches!(a.call("object_paste", &json!({"doc":doc,"page":1})), Err(ToolError::Failed(_))), "nothing copied yet");
let listed = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
assert_eq!(listed["count"], 2);
let copied = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]}));
assert_eq!(copied["copied"], 2);
let text = copied["text"].as_str().unwrap();
assert!(text.contains("Added label") && text.contains("Page 1"), "{text}");
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), listed, "a copy changes nothing");
// Into another document: the same objects, where they were, as its own page text.
let before = ok(&mut a, "object_list", json!({"doc":other,"page":2}));
assert_eq!(ok(&mut a, "object_paste", json!({"doc":other,"page":2}))["pasted"], 2);
let pasted = ok(&mut a, "object_list", json!({"doc":other,"page":2}));
assert_eq!(pasted["count"], 3);
let rect_of =
|list: &Value, text: &str| list["objects"].as_array().unwrap().iter().find(|o| o["text"] == text).map(|o| o["rect"].clone()).unwrap();
assert_eq!(rect_of(&pasted, "Page 1"), rect_of(&listed, "Page 1"));
// The added text box comes as the page's own text, drawn inside the box it had.
let (label, box_) = (rect_of(&pasted, "Added label"), rect_of(&listed, "Added label"));
assert!((0..2).all(|i| label[i].as_f64() >= box_[i].as_f64()) && (2..4).all(|i| label[i].as_f64() <= box_[i].as_f64()), "{label} in {box_}");
assert!(page_text(&mut a, other)[1].contains("Added label"));
ok(&mut a, "doc_save", json!({"doc":other,"path":"pasted.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path":"pasted.pdf"}))["doc"].as_u64().unwrap();
assert!(page_text(&mut a, reopened)[1].contains("Page 1"));
ok(&mut a, "edit_undo", json!({"doc":other}));
assert_eq!(ok(&mut a, "object_list", json!({"doc":other,"page":2}))["objects"], before["objects"], "one undo step");
// Onto its own page: 18 points down and to the right.
ok(&mut a, "object_paste", json!({"doc":doc,"page":1}));
let both = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
assert_eq!(both["count"], 4);
let original = rect_of(&listed, "Page 1");
let moved =
both["objects"].as_array().unwrap().iter().filter(|o| o["text"] == "Page 1").map(|o| o["rect"].clone()).find(|r| *r != original).unwrap();
for i in 0..4 {
assert!((moved[i].as_f64().unwrap() - original[i].as_f64().unwrap() - 18.0).abs() < 0.02, "{moved} {original}");
}
// Cut: copied, then deleted as one undo step.
let cut = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":1}],"cut":true}));
assert_eq!(cut["copied"], 1);
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["count"], 3);
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["objects"], both["objects"]);
for args in [
json!({"doc":doc,"page":1,"all":true,"objects":[{"kind":"text","index":1}]}),
json!({"doc":doc,"page":1}),
json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":99}]}),
json!({"doc":doc,"page":9,"all":true}),
] {
assert!(a.call("object_copy", &args).is_err(), "{args}");
}
assert!(
matches!(a.call("object_copy", &json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]})), Err(ToolError::Failed(_))),
"stale"
);
assert!(a.call("object_paste", &json!({"doc":doc,"page":9})).is_err());
}
#[test]
fn object_move_rejects_invalid_nested_references_without_any_change() {
let dir = workdir("object-move-refusals");
+3
View File
@@ -21,6 +21,9 @@ kurbo = { workspace = true }
# Display order of right-to-left lines in added text, and reading and writing order in Edit text;
# already in the tree via the interface.
unicode-bidi = "0.3"
# Thai word boundaries for rewrapping and Add text (dictionary data compiled in, no LSTM model);
# the ICU4X 2.3 crates it builds on are already in the tree.
icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] }
[dev-dependencies]
hayro-syntax = { workspace = true }
+5 -5
View File
@@ -8,12 +8,8 @@
//! after `/UserUnit` and `/Rotate`), so items stay upright on rotated pages; an item records the
//! `/UserUnit` it was written for.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi,
};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi};
use unicode_bidi::{Level, ParagraphBidiInfo};
use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged};
@@ -30,7 +26,11 @@ use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped};
use layout::Piece;
#[cfg(test)]
pub(crate) use layout::WRAP_CALLS;
#[cfg(test)]
pub(crate) use layout::shaped_width;
pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width};
#[cfg(test)]
use pdfcraft_fonts::ShapedCluster;
pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content};
pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan};
+29 -172
View File
@@ -3,17 +3,17 @@ use super::layout::{
};
use super::types::{AddedText, Align, Family, Ink};
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
Content, Dict, Document, EditError, GlyphError, GlyphOutline, Object, PdfString, ShapedScript, align_code, literal, matrix_number, n,
shaped_glyph, win_ansi,
};
use pdfcraft_fonts::shaped_text::{self, ShapedFonts};
fn font_name(base: &str) -> String {
format!("PCF{}", base.replace('-', ""))
}
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font
/// per 240 distinct glyphs it needs.
pub(super) const GLYPHS_PER_FONT: usize = 240;
#[cfg(test)]
pub(super) use pdfcraft_fonts::shaped_text::GLYPHS_PER_FONT;
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
/// its paragraph, its pieces in drawing order and its width at the item's size.
@@ -61,50 +61,15 @@ pub(super) fn draw_laid_shaped(
) -> Result<(), EditError> {
let r = text_rect(t);
let latin = font_name(t.family.base_font(t.bold, t.italic));
// Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of
// one of the fonts, so copy and search get each character exactly once.
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p {
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
index.entry(cluster_key(c)).or_insert_with(|| {
keys.push(c);
keys.len() - 1
});
}
// Every outline is read before anything is written, so a failure leaves the document as it was.
let mut outlines = HashMap::new();
for c in &keys {
for (id, _) in &c.glyphs {
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
ShapedScript::Arabic => arabic_error(e, &c.text),
ShapedScript::Hebrew => hebrew_error(e, &c.text),
ShapedScript::Thai => thai_error(e, &c.text),
})?);
}
}
}
// The names the page and this item already use: a new font never takes one of them.
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
let mut names = Vec::new();
for chunk in keys.chunks(GLYPHS_PER_FONT) {
let font = type3_shaped(doc, chunk, &outlines);
// A name no font on the page has, so items never replace each other's fonts (object
// numbers alone don't do: a full save renumbers objects but keeps resource names).
let mut name = format!("PCAr{}", font.num);
let mut suffix = 0usize;
while taken_names.contains(name.as_bytes()) {
suffix = suffix.saturating_add(1);
name = format!("PCAr{}_{suffix}", font.num);
}
taken_names.insert(name.clone().into_bytes());
fonts.set(name.clone().into_bytes(), Object::Ref(font));
names.push(name);
}
let shaped =
ShapedFonts::new(shaped_text::clusters(laid.iter().map(|(_, _, _, pieces, _)| pieces.as_slice())), outline, taken, fonts, "PCAr", &mut |o| {
Object::Ref(doc.add(o))
})
.map_err(|(e, c)| match c.script {
ShapedScript::Arabic => arabic_error(e, &c.text),
ShapedScript::Hebrew => hebrew_error(e, &c.text),
ShapedScript::Thai => thai_error(e, &c.text),
})?;
out.extend(format!("BT {}\n", t.fill_op()).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
@@ -119,32 +84,17 @@ pub(super) fn draw_laid_shaped(
let (x, tw) = placed(t, r, &paragraphs, i, *w, spaces, *rtl);
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
let mut pen = x;
let mut current: Option<&str> = None;
let mut current = None;
for piece in pieces {
match piece {
Piece::Latin(s) => {
out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes());
out.extend(literal(&win_ansi(s)));
out.extend_from_slice(b" Tj\n");
current = Some(latin.as_str());
current = None;
pen += latin_width(t, s) + tw * s.matches(' ').count() as f64;
}
Piece::Shaped(clusters) => {
for c in clusters {
let Some(&k) = index.get(&cluster_key(c)) else { continue };
let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue };
// Codes start at 1; k % 240 + 1 is at most 240.
let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1);
if current != Some(name.as_str()) {
out.extend(format!("/{name} {} Tf ", n(t.size)).bytes());
current = Some(name.as_str());
}
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
out.extend(literal(&[code]));
out.extend_from_slice(b" Tj\n");
pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 };
}
}
Piece::Shaped(clusters) => pen = shaped.show(out, clusters, pen, y, t.size, t.char_spacing, tw, &mut current),
}
}
out.extend_from_slice(b"EMC\n");
@@ -153,104 +103,6 @@ pub(super) fn draw_laid_shaped(
Ok(())
}
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
/// equal clusters come from the same font units).
type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64);
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
(c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
}
/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`.
fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef {
let mut charprocs = Dict::new();
let mut differences = vec![Object::Int(1)];
let mut widths = Vec::with_capacity(clusters.len());
let mut bbox = [0.0f64; 4];
let mut to_unicode = Vec::new();
for (i, c) in clusters.iter().enumerate() {
let code = i + 1;
// Glyph space is 1000 units per em.
let mut paths = Vec::new();
let mut ink: Option<[f64; 4]> = None;
for (id, [dx, dy]) in &c.glyphs {
let Some(g) = outlines.get(&(c.script, *id)) else { continue };
for contour in &g.contours {
for (j, p) in contour.iter().enumerate() {
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
}
paths.extend_from_slice(b"h\n");
let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy];
ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]));
}
}
// Rounded outwards so no ink is clipped.
let [x0, y0, x1, y1] =
ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]);
bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)];
let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes();
if !paths.is_empty() {
proc.extend(paths);
proc.extend_from_slice(b"f\n");
}
let glyph = format!("g{code:02X}");
charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc)))));
differences.push(Object::name(&glyph));
widths.push(Object::Real((c.advance * 1000.0).round()));
if !c.text.is_empty() {
let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
to_unicode.push(format!("<{code:02X}> <{hex}>\n"));
}
}
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
// A CMap block holds at most 100 entries.
for block in to_unicode.chunks(100) {
cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat()));
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
// The face's family when every glyph comes from one script; without the face, the script's
// name (as Arabic items were always named).
let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) {
Some(script) => script.font().map_or(
match script {
ShapedScript::Arabic => "Arabic",
ShapedScript::Hebrew => "Hebrew",
ShapedScript::Thai => "Thai",
},
|f| f.family,
),
None => "PdfCraft Shaped Text",
};
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec())));
descriptor.set(b"Flags".to_vec(), Object::Int(4));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect()));
font.set(
b"FontMatrix".to_vec(),
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
);
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(widths));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
doc.add(Object::Dict(font))
}
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or
/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to
@@ -401,9 +253,9 @@ pub(crate) fn thai_glyph_resources(
if shaped.glyphs.len() > 65_534 {
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
}
let mut outlines = HashMap::new();
let mut outlines = std::collections::HashMap::new();
let mut clusters = Vec::new();
let mut index = HashMap::new();
let mut index = std::collections::HashMap::new();
let mut indices = Vec::new();
for glyph in &shaped.glyphs {
let id = glyph.id.to_u32();
@@ -414,7 +266,12 @@ pub(crate) fn thai_glyph_resources(
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() });
clusters.push(pdfcraft_fonts::ShapedCluster {
script: ShapedScript::Thai,
glyphs: vec![(id, [0.0, 0.0])],
advance: width,
text: glyph.text.clone(),
});
at
});
indices.push(at);
@@ -422,9 +279,9 @@ pub(crate) fn thai_glyph_resources(
// 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) {
for chunk in clusters.chunks(shaped_text::GLYPHS_PER_FONT) {
let refs: Vec<_> = chunk.iter().collect();
let font = type3_shaped(doc, &refs, &outlines);
let font = shaped_text::add_type3_font(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()) {
@@ -436,8 +293,8 @@ pub(crate) fn thai_glyph_resources(
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()))?;
let name = names.get(at / shaped_text::GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?;
let code = u8::try_from(at % shaped_text::GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?;
Ok((name.clone(), vec![code]))
})
.collect()
+124 -41
View File
@@ -1,7 +1,6 @@
use super::types::{AddedImage, AddedText};
use super::{
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew,
shape_thai,
Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedScript, shape_arabic, shape_hebrew, shape_thai,
};
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
@@ -73,8 +72,11 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
out
}
/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base.
/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation.
/// Thai wrapping: a row ends before a word that would overflow it, the words found by the
/// dictionary ([`crate::linebreak`]) inside runs without spaces and at the spaces between them. The
/// spaces where a row ends are dropped, as in the Latin wrapper. Only a word wider than the row is
/// broken inside, at shaped cluster boundaries, which never detach a tone or vowel mark from its
/// base.
fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, EditError> {
let (pieces, _) = shaped_layout(t, para, false)?;
let mut atoms = Vec::new();
@@ -88,24 +90,88 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, Edit
})),
}
}
let mut lines = Vec::new();
let (mut line, mut used) = (String::new(), 0.0);
for (text, advance) in atoms {
if !line.is_empty() && used + advance > width {
lines.push(std::mem::take(&mut line));
used = 0.0;
}
line.push_str(&text);
used += advance;
}
lines.push(line);
Ok(lines)
Ok(wrap_words(thai_words(atoms), width))
}
pub(super) fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
/// `words` (each a list of text and advance, with the spaces that follow it) laid into rows no
/// wider than `width`: a word that doesn't fit what is left of a row starts the next one, and only
/// a word wider than a whole row is split, between its clusters.
fn wrap_words(words: Vec<Vec<(String, f64)>>, width: f64) -> Vec<String> {
let blank = |s: &str| s.chars().all(char::is_whitespace);
let mut lines: Vec<String> = Vec::new();
let (mut line, mut used) = (String::new(), 0.0);
let end_row = |lines: &mut Vec<String>, line: &mut String| {
let done = std::mem::take(line);
lines.push(done.trim_end().to_string());
};
for word in words {
// The word's own width: the spaces after it may hang past the row's end.
let ink: f64 = word.iter().filter(|(s, _)| !blank(s)).map(|(_, w)| w).sum();
// A row holding only the spaces that open the paragraph has nothing to end yet.
if !blank(&line) && used + ink > width {
end_row(&mut lines, &mut line);
used = 0.0;
}
// Too wide for what is left of the row, counting the spaces that open the paragraph.
let too_wide = blank(&line) && used + ink > width;
for (text, advance) in word {
if too_wide && !blank(&line) && !blank(&text) && used + advance > width {
end_row(&mut lines, &mut line);
used = 0.0;
}
// A row after a wrap does not begin with the spaces where the last one ended.
if line.is_empty() && !lines.is_empty() && blank(&text) {
continue;
}
line.push_str(&text);
used += advance;
}
}
lines.push(line);
lines
}
/// The shaped clusters `atoms` (text and advance) grouped into words: a word starts after spaces,
/// or where [`crate::linebreak::breaks`] lets a row end inside a run without spaces, and keeps the
/// spaces that follow it. A break that falls inside a cluster is not one.
fn thai_words(atoms: Vec<(String, f64)>) -> Vec<Vec<(String, f64)>> {
let text: String = atoms.iter().map(|(s, _)| s.as_str()).collect();
let mut cuts = Vec::new();
let mut run_start: Option<usize> = None;
for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) {
match (c.is_whitespace(), run_start) {
(false, None) => {
cuts.push(at);
run_start = Some(at);
}
(true, Some(start)) => {
let run = text.get(start..at).unwrap_or_default();
cuts.extend(crate::linebreak::breaks(run).into_iter().map(|b| start.saturating_add(b)));
run_start = None;
}
_ => {}
}
}
// `cuts` is in order: each run's start, then the breaks inside it, then the next run's start.
let mut words: Vec<Vec<(String, f64)>> = Vec::new();
let mut cuts = cuts.into_iter().peekable();
let mut offset = 0usize;
for atom in atoms {
while cuts.peek().is_some_and(|&c| c < offset) {
cuts.next();
}
let starts_word = cuts.peek() == Some(&offset);
offset = offset.saturating_add(atom.0.len());
match words.last_mut() {
Some(word) if !starts_word => word.push(atom),
_ => words.push(vec![atom]),
}
}
words
}
pub(super) use pdfcraft_fonts::shaped_text::{Piece, is_thai};
/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
/// craft-fonts Arabic face.
pub(super) fn is_arabic(c: char) -> bool {
@@ -184,13 +250,6 @@ fn mirrored(c: char) -> char {
}
}
/// A piece of a line with shaped text, in drawing order.
pub(super) enum Piece {
/// Shown in the item's own standard font.
Latin(String),
Shaped(Vec<ShapedCluster>),
}
/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
/// its width at the item's size. `rtl` is its paragraph's direction.
pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
@@ -244,20 +303,13 @@ pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec
});
}
}
let width = pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => latin_width(t, s),
// Each cluster is shown on its own, so the spacing follows each once.
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::<f64>(),
})
.sum();
let width = pdfcraft_fonts::shaped_text::pieces_width(&pieces, t.size, t.char_spacing, |s, _| latin_width(t, s));
Ok((pieces, width))
}
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
pub(crate) fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s))
}
@@ -282,13 +334,7 @@ pub(super) fn hebrew_error(e: GlyphError, text: &str) -> EditError {
}
pub(super) fn thai_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => {
"Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into()
}
GlyphError::Missing => format!("the Thai font can't show \"{text}\""),
GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(),
})
EditError::Invalid(pdfcraft_fonts::shaped_text::thai_message(e, text))
}
/// The box a text item occupies (height from its lines).
@@ -357,3 +403,40 @@ fn copy_key(c: &Content) -> Content {
pub fn text_width(t: &AddedText) -> f64 {
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
}
#[cfg(test)]
mod thai_wrap_tests {
use super::{thai_words, wrap_words};
/// One atom per character, each `advance` wide: what shaping hands over, without a Thai face.
fn atoms(text: &str, advance: f64) -> Vec<(String, f64)> {
text.chars().map(|c| (c.to_string(), advance)).collect()
}
fn texts(words: &[Vec<(String, f64)>]) -> Vec<String> {
words.iter().map(|w| w.iter().map(|(s, _)| s.as_str()).collect()).collect()
}
#[test]
fn thai_atoms_group_into_dictionary_words_with_their_spaces() {
assert_eq!(texts(&thai_words(atoms("น้ำประปาสะอาด", 1.0))), ["น้ำ", "ประปา", "สะอาด"]);
assert_eq!(texts(&thai_words(atoms("สวัสดี ครับ", 1.0))), ["สวัสดี ", "ครับ"]);
// A break the dictionary finds inside one shaped cluster is not a place to end a row.
assert_eq!(texts(&thai_words(vec![("น้ำประปา".into(), 8.0)])), ["น้ำประปา"]);
assert!(thai_words(Vec::new()).is_empty());
}
#[test]
fn words_wrap_whole_and_only_an_overlong_word_is_split() {
let words = || vec![vec![("ab".into(), 2.0), (" ".into(), 1.0)], vec![("cd".into(), 2.0)]];
assert_eq!(wrap_words(words(), 10.0), ["ab cd"]);
// The space that ends a row hangs past it, and the next row doesn't begin with it.
assert_eq!(wrap_words(words(), 3.0), ["ab", "cd"]);
let long = vec![vec![("a".into(), 2.0), ("b".into(), 2.0), ("c".into(), 2.0)]];
assert_eq!(wrap_words(long, 3.0), ["a", "b", "c"]);
// Spaces that open a paragraph stay, and a word too wide for the rest of their row is split.
let opening = vec![vec![(" ".into(), 1.0)], vec![("a".into(), 2.0), ("b".into(), 2.0)]];
assert_eq!(wrap_words(opening, 3.0), [" a", "b"]);
assert_eq!(wrap_words(Vec::new(), 3.0), [""]);
}
}
+45 -10
View File
@@ -1,9 +1,12 @@
use pdfcraft_fonts::shaped_text::{is_thai, thai_refusal, thai_width};
use super::marks::{
Background, Context, HeaderFooter, MarkKind, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, shrink_matrix,
Background, Context, HeaderFooter, MarkKind, MarkText, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb,
shrink_matrix,
};
use super::place::{
END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place,
place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role,
place_inside_wrapper, stream_tag, undrawable, wrapper_role,
};
use super::{Document, EditError, Object, added, backdrop, helvetica_width};
@@ -31,6 +34,9 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
if hf.text.iter().all(|t| t.trim().is_empty()) {
return Err(EditError::Invalid("type the header or footer text first".into()));
}
if let Some(why) = hf.text.iter().find_map(|t| thai_refusal(t)) {
return Err(EditError::Invalid(why));
}
if let Some(c) = hf.first_undrawable() {
return Err(undrawable(c));
}
@@ -73,12 +79,23 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
}
let all = page_list(doc);
let count = all.len();
let number_of = |k: usize| {
u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))
};
let bates_of = |k: usize| bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()));
// Every Thai line drawn on any page (a Bates prefix may join a template's Thai), for its fonts.
let mut thai_lines = std::collections::BTreeSet::new();
if hf.text.iter().any(|t| t.chars().any(is_thai)) {
for k in 0..pages.len() {
let (number, bates) = (number_of(k)?, bates_of(k)?);
thai_lines.extend(hf.text.iter().flat_map(|t| expand(t, number, count, bates, cx).lines().map(str::to_owned).collect::<Vec<_>>()));
}
}
let thai = MarkText::new(doc, &all, pages, thai_lines.iter().map(String::as_str))?;
for (k, &i) in pages.iter().enumerate() {
let page = &all[i];
let (w, h) = page.display_size(doc);
let number =
u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?;
let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?;
let (number, bates) = (number_of(k)?, bates_of(k)?);
let size = hf.font_size;
let [top, bottom, left, right] = hf.margins;
// The page resource the face is added under: the usual name, unless the page already
@@ -91,6 +108,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
let base = hf.font.base_font(false, false);
let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base);
let mut body: Vec<u8> = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
let mut current = None;
let mut underlines = String::new();
for (slot, template) in hf.text.iter().enumerate() {
if template.trim().is_empty() {
@@ -101,7 +119,8 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
let line_h = size * 1.2;
for (li, line) in lines.iter().enumerate() {
// Centred and right-aligned text is placed by its width in the chosen face.
let tw = hf.font.width(line, size, false, false);
let face = |s: &str, size: f64| hf.font.width(s, size, false, false);
let tw = thai_width(line, size, face);
let x = match slot % 3 {
0 => left,
1 => (w - tw) / 2.0,
@@ -109,7 +128,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
};
// Headers hang below the top margin; footers sit on the bottom margin.
let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h };
body.extend(text_op(x, y, line));
thai.show(&mut body, line, x, y, size, &resource, |s| face(s, size), &mut current);
if hf.underline {
underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4))));
}
@@ -126,6 +145,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo
if !page_fonts(doc, page).contains(resource.as_bytes()) {
add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?;
}
thai.add_to(doc, &page_list(doc)[i])?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
if let Some(Some(m)) = shrinks.get(k) {
@@ -150,6 +170,9 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
if text.is_empty() && wm.source.is_none() {
return Err(EditError::Invalid("type the watermark text first".into()));
}
if let Some(why) = wm.source.is_none().then(|| thai_refusal(text)).flatten() {
return Err(EditError::Invalid(why));
}
if let Some(c) = wm.first_undrawable() {
return Err(undrawable(c));
}
@@ -171,6 +194,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
}
let opacity = wm.opacity.clamp(0.0, 1.0);
let lines: Vec<&str> = text.lines().collect();
let thai = if wm.source.is_none() { MarkText::new(doc, &page_list(doc), pages, lines.iter().copied())? } else { MarkText::default() };
for &i in pages {
let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
@@ -185,7 +209,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?;
continue;
}
let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01);
let widest = lines.iter().map(|l| thai_width(l, 1.0, helvetica_width)).fold(0.0, f64::max).max(0.01);
let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) };
let (s, c) = wm.rotation.to_radians().sin_cos();
let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]);
@@ -207,13 +231,24 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r
);
let line_h = size * 1.15;
let total = line_h * (lines.len() as f64 - 1.0);
let mut current = None;
for (li, line) in lines.iter().enumerate() {
let tw = helvetica_width(line, size);
content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line));
let tw = thai_width(line, size, helvetica_width);
thai.show(
&mut content,
line,
-tw / 2.0,
total / 2.0 - li as f64 * line_h - size * 0.35,
size,
"PCHelv",
|s| helvetica_width(s, size),
&mut current,
);
}
content.extend_from_slice(b"ET\n");
content.extend_from_slice(END.as_bytes());
add_resources(doc, &page, Some(opacity), None)?;
thai.add_to(doc, &page_list(doc)[i])?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::Watermark, content, wm.behind)?;
}
+18
View File
@@ -1884,6 +1884,24 @@ fn crop_image_impl(
Ok(())
}
/// The `Do` of each image `indexes` (from [`page_images`]) on `page`, as an index among the
/// page's joined operators: what Copy takes of them.
pub(crate) fn image_ops(doc: &Document, page: usize, indexes: &[usize]) -> Result<Vec<usize>, EditError> {
if indexes.is_empty() {
return Ok(Vec::new());
}
let images = page_images(doc, page)?;
indexes
.iter()
.map(|i| {
images
.get(*i)
.map(|image| image.op)
.ok_or_else(|| EditError::Invalid("a selected image no longer exists; select the objects again".into()))
})
.collect()
}
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
+4 -2
View File
@@ -23,7 +23,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
use pdfcraft_fonts::helvetica_width;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum EditError {
@@ -46,7 +46,9 @@ pub use background::page_background;
mod rules;
pub use rules::page_rules;
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
pub use objects::{
CopiedObjects, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, copy_objects, editable_objects, move_objects, paste_objects,
};
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
+142 -14
View File
@@ -6,8 +6,56 @@
//! and quotes, currency signs before an amount): the line-start and line-end prohibitions of
//! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR.
//!
//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose
//! words need a dictionary to find.
//! Thai is also written without spaces between words, but a row may not end between any two of its
//! characters: its words need a dictionary to find. They come from ICU4X's dictionary line
//! segmenter (UAX #14 with dictionary breaking for class SA), kept only where a Thai character is on
//! either side. A row never begins with a following vowel, a tone or other mark, Mai Yamok (ๆ) or
//! Paiyannoi (ฯ), nor ends with a leading vowel (เ แ โ ใ ไ), whatever the dictionary says.
//!
//! Korean is written with spaces between words and keeps breaking only there.
use std::sync::LazyLock;
use icu_segmenter::options::LineBreakOptions;
use icu_segmenter::{LineSegmenter, LineSegmenterBorrowed};
/// The dictionary segmenter, built once: its data is compiled in, so building it cannot fail.
static SEGMENTER: LazyLock<LineSegmenterBorrowed<'static>> = LazyLock::new(|| LineSegmenter::new_dictionary(LineBreakOptions::default()));
/// Letters, vowels, marks, digits and signs of the Thai block.
fn thai(c: char) -> bool {
matches!(c, '\u{0e01}'..='\u{0e5b}')
}
/// Thai that belongs to the character before it: Paiyannoi, the following vowels, Lakkhangyao,
/// Mai Yamok and the tone and other marks.
fn thai_no_start(c: char) -> bool {
matches!(c, '\u{0e2f}' | '\u{0e30}'..='\u{0e3a}' | '\u{0e45}'..='\u{0e4e}')
}
/// The leading vowels, written before the consonant they follow in speech.
fn thai_no_end(c: char) -> bool {
matches!(c, '\u{0e40}'..='\u{0e44}')
}
/// Byte offsets where the dictionary lets a row of Thai end inside `word`.
fn thai_breaks(word: &str) -> Vec<usize> {
if !word.chars().any(thai) {
return Vec::new();
}
SEGMENTER
.segment_str(word)
.filter(|&at| {
let before = word.get(..at).and_then(|s| s.chars().next_back());
let after = word.get(at..).and_then(|s| s.chars().next());
match (before, after) {
(Some(b), Some(a)) => (thai(b) || thai(a)) && !thai_no_start(a) && !thai_no_end(b) && !no_start(a) && !no_end(b),
// The start and end of the word are not breaks inside it.
_ => false,
}
})
.collect()
}
/// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID).
fn ideographic(c: char) -> bool {
@@ -77,29 +125,49 @@ fn no_end(c: char) -> bool {
)
}
/// Whether a row may end between `before` and `after`, neither of them a space.
pub(crate) fn between(before: char, after: char) -> bool {
(ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before)
/// Whether a row may end between `before` and `after` in Chinese or Japanese, neither of them a
/// space. Thai needs the whole word ([`breaks`]).
fn between(before: char, after: char) -> bool {
(ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) && !thai_no_start(after) && !thai_no_end(before)
}
/// Byte offsets inside `word` (text without spaces) where a row may end, in order: never 0 or the
/// word's length, always on a character boundary.
pub(crate) fn breaks(word: &str) -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
let mut prev: Option<char> = None;
for (at, c) in word.char_indices() {
if let Some(before) = prev
&& between(before, c)
{
out.push(at);
}
prev = Some(c);
}
out.extend(thai_breaks(word));
out.sort_unstable();
out.dedup();
out
}
/// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing.
pub(crate) fn pieces(word: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut prev: Option<char> = None;
for (at, c) in word.char_indices() {
if let Some(before) = prev
&& between(before, c)
{
out.extend(word.get(start..at));
start = at;
}
prev = Some(c);
for at in breaks(word) {
out.extend(word.get(start..at));
start = at;
}
out.extend(word.get(start..).filter(|rest| !rest.is_empty()));
out
}
/// For each character of `word` (text without spaces), whether a row may end just before it.
pub(crate) fn breaks_before_chars(word: &str) -> Vec<bool> {
let cuts = breaks(word);
word.char_indices().map(|(at, _)| cuts.binary_search(&at).is_ok()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -121,4 +189,64 @@ mod tests {
assert_eq!(pieces(""), Vec::<&str>::new());
assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字'));
}
#[test]
fn thai_breaks_between_dictionary_words() {
assert_eq!(pieces("ภาษาไทยไม่มีช่องว่างระหว่างคำ"), ["ภาษา", "ไทย", "ไม่มี", "ช่อง", "ว่าง", "ระหว่าง", "คำ"]);
assert_eq!(pieces("น้ำประปาสะอาด"), ["น้ำ", "ประปา", "สะอาด"]);
assert_eq!(pieces("การประปาส่วนภูมิภาค"), ["การ", "ประปา", "ส่วน", "ภูมิภาค"]);
}
#[test]
fn thai_vowels_marks_and_repetition_stay_with_the_word_before_them() {
// The dictionary alone would start a row with Mai Yamok, with the Sara Aa of a decomposed
// Sara Am, or with a closing bracket.
assert_eq!(pieces("เด็กๆชอบกินขนม"), ["เด็กๆ", "ชอบ", "กิน", "ขนม"]);
assert_eq!(pieces("ท\u{0e4d}\u{0e32}งาน"), ["ท\u{0e4d}\u{0e32}", "งาน"]);
assert_eq!(pieces("กรุงเทพฯเป็นเมืองหลวง"), ["กรุงเทพฯ", "เป็น", "เมือง", "หลวง"]);
assert_eq!(pieces("สวัสดีครับ(ทดสอบ)"), ["สวัสดี", "ครับ", "(ทดสอบ)"]);
}
#[test]
fn thai_keeps_numbers_and_latin_whole() {
assert_eq!(pieces("ราคา100บาทต่อเดือน"), ["ราคา", "100บาท", "ต่อ", "เดือน"]);
assert_eq!(pieces("พ.ศ.2569"), ["พ.ศ.2569"]);
assert_eq!(pieces("PDF"), ["PDF"]);
assert_eq!(breaks("ไทย"), Vec::<usize>::new());
}
#[test]
fn breaks_before_chars_marks_the_first_character_of_each_later_piece() {
let flags = breaks_before_chars("น้ำประปา");
let starts: Vec<usize> = flags.iter().enumerate().filter_map(|(i, &b)| b.then_some(i)).collect();
// น ้ ำ | ป ร ะ ป า
assert_eq!(starts, [3]);
assert_eq!(breaks_before_chars(""), Vec::<bool>::new());
}
#[test]
fn pieces_of_arbitrary_thai_rejoin_to_the_input_and_never_start_with_a_mark() {
// A deterministic generator over Thai, Latin, digits and CJK, including mark-only runs and
// unusual orders that no dictionary word has.
let alphabet: Vec<char> = ('\u{0e01}'..='\u{0e5b}').chain(['a', '1', '(', ')', '字', 'の', '.']).collect();
let mut state: u64 = 0x2545_f491_4f6c_dd1d;
for _ in 0..2000 {
let mut word = String::new();
state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let len = (state >> 59) as usize + 1;
for _ in 0..len {
state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let pick = (state >> 33) as usize % alphabet.len();
word.extend(alphabet.get(pick));
}
let parts = pieces(&word);
assert_eq!(parts.concat(), word);
assert!(parts.iter().all(|p| !p.is_empty()), "{word:?} gave {parts:?}");
for part in parts.iter().skip(1) {
let first = part.chars().next().unwrap_or(' ');
assert!(!thai_no_start(first) && !no_start(first), "{word:?} gave {parts:?}");
}
assert_eq!(breaks_before_chars(&word).len(), word.chars().count());
}
}
}
+82 -10
View File
@@ -1,4 +1,75 @@
use super::{Document, EditError, Object, Rgb, added};
use pdfcraft_fonts::shaped_text::{Piece, ShapedFonts, clusters, first_undrawable_in_line, is_thai, thai_message, thai_pieces};
use super::{Dict, Document, EditError, Object, Rgb, added};
/// The Thai of a header, footer or text watermark, drawn from the Thai face as Add text draws it:
/// Type 3 fonts added to the document once and to each page the mark goes on, beside the page
/// font that draws the rest.
#[derive(Default)]
pub(crate) struct MarkText {
shaped: ShapedFonts,
/// The fonts, by the resource names the marked pages give them.
fonts: Dict,
}
impl MarkText {
/// The fonts for the Thai in `lines` (every line the mark draws, on any page), named so that
/// none of `pages` (of `all`) has the name already. Thai this build can't draw (it has no Thai
/// face, or the face lacks a character) is refused, saying what to do.
pub(crate) fn new<'a>(
doc: &mut Document,
all: &[pdfcraft_model::Page],
pages: &[usize],
lines: impl IntoIterator<Item = &'a str>,
) -> Result<Self, EditError> {
let thai = |line: &str| thai_pieces(line).map_err(|e| EditError::Invalid(thai_message(e, line)));
let pieces = lines.into_iter().filter(|l| l.chars().any(is_thai)).map(thai).collect::<Result<Vec<_>, _>>()?;
if pieces.is_empty() {
return Ok(Self::default());
}
let mut taken = Dict::new();
for page in pages.iter().filter_map(|&i| all.get(i)) {
for (name, font) in crate::place::page_fonts(doc, page).iter() {
taken.set(name.clone(), font.clone());
}
}
let mut fonts = Dict::new();
let shaped =
ShapedFonts::new(clusters(pieces.iter().map(Vec::as_slice)), pdfcraft_fonts::shaped_glyph, &taken, &mut fonts, "PCTh", &mut |o| {
Object::Ref(doc.add(o))
})
.map_err(|(e, c)| EditError::Invalid(thai_message(e, &c.text)))?;
Ok(Self { shaped, fonts })
}
/// Show `line` inside `BT … ET` from `x` on baseline `y` at `size`: its Thai from the fonts,
/// the rest in the page font `latin` (selected when `current` is `None`), `width` wide.
#[allow(clippy::too_many_arguments)]
pub(crate) fn show(
&self,
out: &mut Vec<u8>,
line: &str,
x: f64,
y: f64,
size: f64,
latin: &str,
width: impl Fn(&str) -> f64,
current: &mut Option<usize>,
) {
let plain = || vec![Piece::Latin(line.into())];
// `new` shaped every line drawn, so this shapes.
let pieces = if self.fonts.is_empty() || !line.chars().any(is_thai) { plain() } else { thai_pieces(line).unwrap_or_else(|_| plain()) };
self.shaped.show_line(out, &pieces, x, y, size, latin, pdfcraft_fonts::win_ansi, width, current);
}
/// Add the fonts to `page`'s own resources.
pub(crate) fn add_to(&self, doc: &mut Document, page: &pdfcraft_model::Page) -> Result<(), EditError> {
if self.fonts.is_empty() {
return Ok(());
}
crate::place::add_page_fonts(doc, page, &self.fonts)
}
}
/// The kinds of page marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -61,13 +132,13 @@ impl Default for HeaderFooter {
}
impl HeaderFooter {
/// The first character [`add_header_footer`] would refuse, because the standard fonts can't
/// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the
/// template), so checking the templates, split as `expand`'s output is, covers every page
/// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview
/// never offers text that applying refuses (#2013).
/// The first character [`add_header_footer`] would refuse, because neither the standard
/// fonts nor (for Thai) the Thai face can draw it (#125). Tokens only add ASCII (Bates
/// prefixes and suffixes are part of the template), so checking the templates, split as
/// `expand`'s output is, covers every page (and a `\r` that would be drawn is refused too).
/// The dialog asks this too, so its preview never offers text that applying refuses (#2013).
pub fn first_undrawable(&self) -> Option<char> {
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
self.text.iter().flat_map(|t| t.lines()).find_map(first_undrawable_in_line)
}
/// The room the header and the footer take at the top and the bottom of a page, in points:
@@ -139,12 +210,13 @@ pub struct Watermark {
}
impl Watermark {
/// The first character of a text watermark [`add_watermark`] would refuse, because the
/// standard fonts can't draw it (#125). A picture watermark draws no text.
/// The first character of a text watermark [`add_watermark`] would refuse, because neither
/// the standard fonts nor (for Thai) the Thai face can draw it (#125). A picture watermark
/// draws no text.
pub fn first_undrawable(&self) -> Option<char> {
match self.source {
Some(_) => None,
None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi),
None => self.text.trim().lines().find_map(first_undrawable_in_line),
}
}
}
+3
View File
@@ -9,6 +9,9 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::text::ContentStream;
use crate::{Content, EditError};
mod clip;
pub use clip::{CopiedObjects, copy_objects, paste_objects};
pub const MAX_MOVE_OBJECTS: usize = 1_000;
const MAX_PAGE_OBJECTS: usize = 10_000;
const MAX_COORDINATE: f64 = 1e9;
+283
View File
@@ -0,0 +1,283 @@
//! Edit a PDF ▸ Copy, Cut and Paste of objects selected together (#5989): paragraphs, images,
//! Form artwork and added content, pasted as page content that draws the same.
//!
//! A copy is a content stream of its own that draws the objects and nothing else, in the page's
//! user space, with the resources it names. It replays every state operator of the page up to
//! the last object (transformations, clips, colours, ExtGStates, text state), so each object is
//! drawn in the state it had, and leaves out what paints anything else: other text, images,
//! shadings and paths (a path that clips is kept, without its paint). Each copied text-showing
//! operator is placed by its own text matrix, which is where its glyphs start, so the text
//! positioning between operators is left out. Marked content is left out too: a paste is
//! untagged and in no layer.
//!
//! A paste draws that stream on top of the page through Ungroup's merge ([`ungroup_ops`]): the
//! copy is read as Form artwork whose resources join the page's under names that don't clash,
//! moved by the offset, with the patterns it paints moved along. Its resources must already be
//! the target document's: the engine copies them over first when the copy came from another
//! document.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use pdfcraft_content::{Matrix, Op, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use super::{EditableObject, MAX_COORDINATE, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, checked_streams, editable_objects, valid_rect};
use crate::EditError;
use crate::ungroup::{CATEGORIES, DEFAULT_SPACES, MAX_FORM_BYTES, Ungrouped, page_blend_space, resource_operand, ungroup_ops};
/// How deep the copied graphics state may nest (`q`), as the page's own may.
const MAX_NESTING: usize = 1024;
/// Objects copied from one page: what [`paste_objects`] draws again.
#[derive(Clone, Debug, PartialEq)]
pub struct CopiedObjects {
/// A content stream that draws the objects alone, in the source page's user space: its `q`
/// and `Q` balance and its text objects close.
pub content: Vec<u8>,
/// The resources `content` names, as the source page has them: references are into the
/// document the copy came from.
pub resources: Dict,
/// The union of the objects' boxes (user space of the source page).
pub rect: [f64; 4],
/// The objects as [`editable_objects`] listed them, in inventory order.
pub objects: Vec<EditableObject>,
}
impl CopiedObjects {
/// The text of the copied paragraphs and text boxes, one per line: what the system clipboard
/// gets for other applications.
pub fn text(&self) -> String {
self.objects.iter().filter_map(|o| o.text.as_deref()).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n")
}
}
/// Copy `targets` (references from one [`editable_objects`] inventory of `page`). The document
/// is not changed.
pub fn copy_objects(doc: &Document, page: usize, targets: &[ObjectTarget]) -> Result<CopiedObjects, EditError> {
if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS {
return Err(EditError::Invalid("select between 1 and 1000 objects to copy".into()));
}
let mut inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect();
let mut objects = Vec::with_capacity(targets.len());
for target in targets.iter().collect::<BTreeSet<_>>() {
objects
.push(inventory.remove(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?);
}
if objects.len() != targets.len() {
return Err(EditError::Invalid("an object was selected more than once".into()));
}
let indexes = |kind| objects.iter().filter(|o| o.target.kind == kind).map(|o| o.target.index).collect::<Vec<_>>();
let streams = checked_streams(doc, page)?;
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut keep = crate::text::paragraph_ops(doc, page, &indexes(ObjectKind::Text), false)?;
keep.extend(crate::images::image_ops(doc, page, &indexes(ObjectKind::Image))?);
// Added content owns its stream: everything that stream paints is the item.
let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect();
let owned = indexes(ObjectKind::Added)
.into_iter()
.map(|i| {
added.get(i).map(|a| a.obj).ok_or_else(|| EditError::Invalid("a selected added item no longer exists; select the objects again".into()))
})
.collect::<Result<BTreeSet<ObjRef>, _>>()?;
if !owned.is_empty() {
for (i, op) in ops.iter().enumerate() {
let stream = streams.get(pieces.pieces_of(op).0).and_then(|s| s.entry.as_ref());
if paints(op) && stream.is_some_and(|r| owned.contains(&r)) {
keep.insert(i);
}
}
}
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let shows = crate::text::show_starts(doc, &ops, &res)?;
let drawn = replay(&ops, &keep, &shows)?;
let rect = objects.iter().map(|o| o.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap_or_default();
Ok(CopiedObjects { resources: used_resources(doc, &res, &drawn), content: serialize_ops(&drawn), rect, objects })
}
/// Whether `op` paints (rather than sets state or builds a path).
fn paints(op: &Op) -> bool {
matches!(op.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI")
}
/// The page's operators up to the last of `keep`, painting only `keep` (see the module docs).
/// `shows`: where each text-showing operator starts ([`crate::text::show_starts`]).
fn replay(ops: &[Op], keep: &BTreeSet<usize>, shows: &HashMap<usize, (Matrix, i64)>) -> Result<Vec<Op>, EditError> {
let Some(&last) = keep.last() else { return Err(EditError::Invalid("the selected objects draw nothing to copy".into())) };
let mut out = Vec::new();
let (mut depth, mut in_text) = (0usize, false);
let (mut path, mut clips) = (Vec::new(), false);
for (i, op) in ops.iter().enumerate().take(last.saturating_add(1)) {
let kept = keep.contains(&i);
match op.op.as_slice() {
b"q" => {
if depth >= MAX_NESTING {
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
}
depth += 1;
out.push(op.clone());
}
// An unmatched `Q` restores nothing; the renderer ignores it too.
b"Q" if depth > 0 => {
depth -= 1;
out.push(op.clone());
}
b"BT" if !in_text => {
in_text = true;
out.push(op.clone());
}
b"ET" if in_text => {
in_text = false;
out.push(op.clone());
}
b"Q" | b"BT" | b"ET" => {}
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path.push(op.clone()),
b"W" | b"W*" => {
clips = true;
path.push(op.clone());
}
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
if kept || clips {
out.append(&mut path);
out.push(if kept { op.clone() } else { Op::new("n", Vec::new()) });
}
path.clear();
clips = false;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
// `"` sets the spacing for what follows, shown or not.
if op.is("\"") {
for (name, value) in ["Tw", "Tc"].into_iter().zip(op.operands.iter().take(2)) {
if value.as_f64().is_some() {
out.push(Op::new(name, vec![value.clone()]));
}
}
}
match shows.get(&i) {
Some((tm, _)) if kept => {
out.push(Op::new("Tm", tm.0.map(Object::Real).to_vec()));
out.push(if op.is("TJ") { op.clone() } else { Op::new("Tj", op.operands.last().cloned().into_iter().collect()) });
}
// Its glyphs clip what follows, which leaving them out would not.
Some((_, mode)) if *mode >= 4 => {
return Err(EditError::Invalid(
"text used as a clipping path is drawn before the selected objects, so they can't be copied".into(),
));
}
_ => {}
}
}
// Each copied show sets its own text matrix; `TD` also sets the leading.
b"TD" => {
if let Some([_, y]) = op.nums::<2>() {
out.push(Op::new("TL", vec![Object::Real(-y)]));
}
}
b"Td" | b"Tm" | b"T*" | b"BDC" | b"BMC" | b"EMC" | b"MP" | b"DP" => {}
b"Do" | b"sh" | b"BI" => {
if kept {
out.push(op.clone());
}
}
_ => out.push(op.clone()),
}
}
if in_text {
out.push(Op::new("ET", Vec::new()));
}
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
Ok(out)
}
/// The entries of the page's resources `res` that `ops` name, and the default colour spaces,
/// which remap the device colours they paint (ISO 32000-2 §8.6.5.6).
fn used_resources(doc: &Document, res: &Dict, ops: &[Op]) -> Dict {
let mut used: BTreeMap<&[u8], BTreeSet<&[u8]>> = BTreeMap::new();
for op in ops {
if let Ok(Some((cat, i))) = resource_operand(op)
&& let Some(Object::Name(name)) = op.operands.get(i)
{
used.entry(cat).or_default().insert(name.as_slice());
}
}
let mut out = Dict::new();
for cat in CATEGORIES {
let Some(all) = res.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) else { continue };
let mut mine = Dict::new();
for (key, value) in all.iter() {
if used.get(cat).is_some_and(|u| u.contains(key.as_slice())) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&key.as_slice())) {
mine.set(key.clone(), value.clone());
}
}
if !mine.is_empty() {
out.set(cat.to_vec(), Object::Dict(mine));
}
}
out
}
/// Paste `copied` on top of `page`, moved by `offset` (user space), as one change: its content
/// and the page's resources merged with its own. Its resources must be this document's.
pub fn paste_objects(doc: &mut Document, page: usize, copied: &CopiedObjects, offset: [f64; 2]) -> Result<(), EditError> {
let r = copied.rect;
if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE)
|| !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]])
{
return Err(EditError::Invalid("the paste position is out of range".into()));
}
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
crate::check_on_top(doc, &p)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let name = (1..=xobjects.len().saturating_add(1))
.map(|n| format!("PCPaste{n}").into_bytes())
.find(|n| xobjects.get(n).is_none())
.ok_or_else(|| EditError::Invalid("the page has too many resources".into()))?;
// The copy as Form artwork, to draw inline as Ungroup does: its resources merge into the
// page's, and patterns move with it. The form itself is never added to the document.
let number = |v: f64| Object::Real(v);
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
// Nothing of the copy is clipped: its glyphs and strokes may reach past its objects' boxes.
form.set(b"BBox".to_vec(), Object::Array([-MAX_COORDINATE, -MAX_COORDINATE, MAX_COORDINATE, MAX_COORDINATE].map(number).to_vec()));
form.set(b"Matrix".to_vec(), Object::Array(Matrix::translate(offset[0], offset[1]).0.map(number).to_vec()));
form.set(b"Resources".to_vec(), Object::Dict(copied.resources.clone()));
xobjects.set(name.clone(), Object::Stream(Stream::from_raw(form, copied.content.clone())));
let mut with_copy = res;
with_copy.set(b"XObject".to_vec(), Object::Dict(xobjects));
let space = page_blend_space(doc, &p.dict);
// Drawn by a `Do` of its own: not translucent, and in no path or text object.
let draw = [Op::new("Do", vec![Object::Name(name.clone())])];
let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_FORM_BYTES, 0);
let Ungrouped { ops, resources: mut merged, patterns, .. } =
ungroup_ops(doc, &with_copy, &draw, 0, space.as_ref(), Matrix::translate(offset[0], offset[1]), &mut budget).map_err(|r| match r.error {
EditError::Invalid(why) => {
EditError::Invalid(why.replacen("this artwork can't be ungrouped", "the copied objects can't be pasted here", 1))
}
other => other,
})?;
let mut work = doc.clone();
if let Some(mut x) = merged.get(b"XObject").and_then(Object::as_dict).cloned() {
x.remove(&name);
merged.set(b"XObject".to_vec(), Object::Dict(x));
}
if !patterns.is_empty() {
let mut table = merged.get(b"Pattern").map(|x| work.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (key, pattern) in patterns {
table.set(key, Object::Ref(work.add(pattern)));
}
merged.set(b"Pattern".to_vec(), Object::Dict(table));
}
work.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(merged)))?;
crate::place_tagged(&mut work, &p, "Pasted", serialize_ops(&ops), false)?;
// Keep the page's objects selectable together, as a move does.
editable_objects(&work, page)?;
*doc = work;
Ok(())
}
#[cfg(test)]
#[path = "clip_tests.rs"]
mod tests;
+228
View File
@@ -0,0 +1,228 @@
use std::sync::Arc;
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
use super::*;
/// A document of `objs` (object 1 the catalog), numbered in order.
fn pdf(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn stream(content: &str) -> String {
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())
}
/// Two 300 × 300 pages. The first draws `source` with Helvetica as /F1, a 4 × 2 RGB image /Im0,
/// a half-opaque ExtGState /GS0 and Courier as /F2; the second draws `target` with Courier
/// under the same name /F1, so a paste from the first must rename the font it brings.
fn two_pages(source: &str, target: &str) -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R /F2 8 0 R >> /XObject << /Im0 9 0 R >> /ExtGState << /GS0 << /ca 0.5 /CA 0.5 >> >> >> >>"
.into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
stream(source),
stream(target),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 24 >>\nstream\n\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{7f}@@@\u{0}\u{0}\u{0}\nendstream".into(),
])
}
/// What the page state, the copied paragraph and picture and the objects left out are drawn with.
const SOURCE: &str = "q 1 0 0 1 10 10 cm 0 0 270 270 re W n 0 0 1 rg /GS0 gs \
BT /F2 10 Tf 200 20 Td (Skip) Tj ET \
BT /F1 24 Tf 1.5 Tc 20 170 Td (Copied) Tj 0 -30 Td (Too) Tj ET \
q 1 0 0 rg 150 230 100 40 re f Q \
q 80 0 0 40 20 60 cm /Im0 Do Q Q \
BT /F1 12 Tf 200 250 Td (After) Tj ET";
fn target(doc: &Document, page: usize, want: impl Fn(&EditableObject) -> bool) -> ObjectTarget {
editable_objects(doc, page).unwrap().into_iter().find(|o| want(o)).unwrap().target
}
fn render(doc: &Document, page: usize) -> pdfcraft_render::RenderedPage {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
renderer.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() })
}
/// The pixels of `page` in user-space box `r` (a page 300 high at scale 1), shifted by `by`.
fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4], by: [f64; 2]) -> Vec<u8> {
let (x0, x1) = ((r[0] + by[0]) as u32, (r[2] + by[0]) as u32);
let (y0, y1) = ((300.0 - r[3] - by[1]) as u32, (300.0 - r[1] - by[1]) as u32);
let mut out = Vec::new();
for y in y0..y1 {
for x in x0..x1 {
let i = ((y * page.width + x) * 4) as usize;
out.extend_from_slice(&page.rgba[i..i + 4]);
}
}
out
}
/// The paragraph "Copied" / "Too" (one or two of them, as the lines group) and the picture.
fn copied_paragraph_and_picture(doc: &Document) -> CopiedObjects {
let targets: Vec<_> = editable_objects(doc, 0)
.unwrap()
.into_iter()
.filter(|o| o.target.kind == ObjectKind::Image || o.text.as_deref().is_some_and(|t| t.contains("Copied") || t.contains("Too")))
.map(|o| o.target)
.collect();
copy_objects(doc, 0, &targets).unwrap()
}
#[test]
fn a_copy_takes_the_selected_objects_their_state_and_only_the_resources_they_use() {
let doc = two_pages(SOURCE, "");
let copied = copied_paragraph_and_picture(&doc);
let content = String::from_utf8_lossy(&copied.content).into_owned();
assert!(content.contains("(Copied)") && content.contains("(Too)") && content.contains("/Im0 Do"), "{content}");
// The page's clip and its colour and transparency still apply; nothing else is painted.
assert!(content.contains("W\nn") && content.contains("/GS0 gs") && content.contains("rg"), "{content}");
assert!(!content.contains("Skip") && !content.contains("After") && !content.contains("\nf\n"), "{content}");
let names = |cat: &[u8]| copied.resources.get(cat).and_then(Object::as_dict).map(|d| d.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>());
assert_eq!(names(b"Font"), Some(vec![b"F1".to_vec(), b"F2".to_vec()]), "the font of the skipped text stays selected for what follows");
assert_eq!(names(b"XObject"), Some(vec![b"Im0".to_vec()]));
assert_eq!(names(b"ExtGState"), Some(vec![b"GS0".to_vec()]));
assert!(copied.text().contains("Copied") && copied.text().contains("Too") && !copied.text().contains("Skip"), "{}", copied.text());
assert_eq!(pdfcraft_content::parse(&copied.content).ops.iter().filter(|o| o.is("q")).count(), 3, "balanced: {content}");
}
#[test]
fn pasted_objects_draw_the_same_on_another_page_under_renamed_resources() {
let mut doc = two_pages(SOURCE, "BT /F1 10 Tf 5 5 Td (x) Tj ET");
let copied = copied_paragraph_and_picture(&doc);
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
let (source, pasted) = (render(&doc, 0), render(&doc, 1));
let area = [copied.rect[0] - 4.0, copied.rect[1] - 4.0, copied.rect[2] + 4.0, copied.rect[3] + 4.0];
let (before, after) = (pixels(&source, area, [0.0, 0.0]), pixels(&pasted, area, [0.0, 0.0]));
assert!(before.chunks(4).any(|p| p != [255, 255, 255, 255]), "the copied objects are drawn");
assert_eq!(before, after, "the paste draws the same pixels");
let red = pixels(&pasted, [160.0, 240.0, 260.0, 280.0], [0.0, 0.0]);
assert!(red.chunks(4).all(|p| p == [255, 255, 255, 255]), "what wasn't selected isn't pasted");
// Its own /F1 (Courier) is left alone; the copy's Helvetica comes under another name.
let fonts =
pdfcraft_model::pages(&doc)[1].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap().as_dict().unwrap().get(b"Font").cloned().unwrap();
let fonts = doc.resolve(&fonts).as_dict().cloned().unwrap();
assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))));
assert!(fonts.iter().any(|(k, v)| k != b"F1" && *v == Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))), "{fonts:?}");
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
assert!(texts.iter().any(|t| t.contains("Copied")) && !texts.iter().any(|t| t.contains("Skip")), "{texts:?}");
assert_eq!(crate::images::page_images(&doc, 1).unwrap().len(), 1);
}
#[test]
fn a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same() {
let mut doc = two_pages(SOURCE, "");
let copied = copied_paragraph_and_picture(&doc);
let before = render(&doc, 0);
paste_objects(&mut doc, 0, &copied, [150.0, 0.0]).unwrap();
let after = render(&doc, 0);
let area = [copied.rect[0] - 2.0, copied.rect[1] - 2.0, copied.rect[2] + 2.0, copied.rect[3] + 2.0];
assert!(area[2] + 150.0 < 300.0, "{area:?}");
// The copy (and the clip it is drawn in) lands 150 pt to the right, the original stays.
// Antialiasing elsewhere on the page may round an edge pixel one step differently.
let (moved, original) = (pixels(&after, area, [150.0, 0.0]), pixels(&before, area, [0.0, 0.0]));
assert_eq!(moved.len(), original.len());
assert!(original.iter().any(|v| *v != 255), "the copied objects are drawn");
assert!(moved.iter().zip(&original).all(|(a, b)| a.abs_diff(*b) <= 2), "the paste draws the same pixels, moved");
assert_eq!(pixels(&after, area, [0.0, 0.0]), original);
// Within the document and the page, the resources are the page's own, under their names.
let res = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap();
let fonts = res.as_dict().unwrap().get(b"Font").and_then(Object::as_dict).cloned().unwrap();
assert_eq!(fonts.len(), 2, "{fonts:?}");
assert_eq!(editable_objects(&doc, 0).unwrap().iter().filter(|o| o.target.kind == ObjectKind::Image).count(), 2);
}
/// Artwork without resources of its own takes the names of the page drawing it. Pasted onto a
/// page whose names differ, artwork that names nothing still draws the same; artwork that names
/// a font would find another one, so the paste is refused.
#[test]
fn artwork_inheriting_resources_pastes_only_when_it_names_none() {
for (art, pastes) in [("0 0 1 rg 0 0 60 30 re f", true), ("BT /F1 9 Tf (n) Tj ET", false)] {
let mut doc = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> /XObject << /Art 8 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 250 Td (Title) Tj ET q 1 0 0 1 20 100 cm /Art Do Q"),
stream(""),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 60 30] /Length {} >>\nstream\n{art}\nendstream", art.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
]);
let all: Vec<_> = editable_objects(&doc, 0).unwrap().into_iter().map(|o| o.target).collect();
let copied = copy_objects(&doc, 0, &all).unwrap();
let pasted = paste_objects(&mut doc, 1, &copied, [0.0, 0.0]);
assert_eq!(pasted.is_ok(), pastes, "{art}: {pasted:?}");
if let Err(EditError::Invalid(why)) = pasted {
assert!(why.starts_with("the copied objects can't be pasted here") && why.contains("uses its resources"), "{why}");
}
}
}
#[test]
fn added_content_is_copied_with_everything_its_stream_paints() {
let mut doc = two_pages(SOURCE, "");
let item = crate::Content::Text(crate::AddedText {
rect: [20.0, 20.0, 200.0, 60.0],
text: "Added box".into(),
size: 12.0,
..crate::AddedText::default()
});
crate::add_content(&mut doc, 0, &item).unwrap();
let added = target(&doc, 0, |o| o.target.kind == ObjectKind::Added);
let copied = copy_objects(&doc, 0, &[added]).unwrap();
assert_eq!(copied.text(), "Added box");
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
assert_eq!(texts, ["Added box"], "pasted as the page's own text");
assert!(crate::list_added(&doc).iter().all(|a| a.page == 0));
}
#[test]
fn copying_and_pasting_refuse_what_they_cannot_do_and_change_nothing() {
let doc = two_pages(SOURCE, "");
let text = target(&doc, 0, |o| o.text.as_deref().is_some_and(|t| t.contains("Copied")));
assert!(copy_objects(&doc, 0, &[]).is_err());
assert!(copy_objects(&doc, 0, &[text, text]).is_err(), "selected twice");
assert!(copy_objects(&doc, 0, &[ObjectTarget { kind: ObjectKind::Image, index: 9 }]).is_err(), "stale");
assert!(copy_objects(&doc, 5, &[text]).is_err(), "no such page");
let copied = copy_objects(&doc, 0, &[text]).unwrap();
for offset in [[f64::NAN, 0.0], [f64::INFINITY, 0.0], [2e9, 0.0]] {
let mut work = doc.clone();
assert!(paste_objects(&mut work, 1, &copied, offset).is_err(), "{offset:?}");
assert!(!work.is_modified(), "a refused paste changes nothing");
}
assert!(paste_objects(&mut doc.clone(), 7, &copied, [0.0, 0.0]).is_err());
// Text drawn as a clipping path before the selection would clip it: refused, not dropped.
let clipped = two_pages("BT /F1 20 Tf 7 Tr 20 20 Td (Mask) Tj ET 0 Tr BT /F1 20 Tf 20 200 Td (Shown) Tj ET", "");
let shown = target(&clipped, 0, |o| o.text.as_deref() == Some("Shown"));
assert!(copy_objects(&clipped, 0, &[shown]).is_err());
// Stray `Q`, `ET` and an unfinished path or text object: the copy is still balanced.
let stray = two_pages("Q Q ET 10 10 m 20 20 l BT /F1 20 Tf 20 200 Td (Stray) Tj", "");
let shown = target(&stray, 0, |o| o.text.as_deref() == Some("Stray"));
let copied = copy_objects(&stray, 0, &[shown]).unwrap();
let ops = pdfcraft_content::parse(&copied.content).ops;
assert_eq!(ops.iter().filter(|o| o.is("q")).count(), ops.iter().filter(|o| o.is("Q")).count());
assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), ops.iter().filter(|o| o.is("ET")).count());
let mut work = stray.clone();
paste_objects(&mut work, 1, &copied, [0.0, 0.0]).unwrap();
}
+15 -9
View File
@@ -1,5 +1,5 @@
use super::marks::{MarkKind, matrix_number, n};
use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi};
use super::marks::{MarkKind, matrix_number};
use super::{Dict, Document, EditError, ObjRef, Object, Stream};
pub(crate) fn page_list(doc: &Document) -> Vec<pdfcraft_model::Page> {
pdfcraft_model::pages(doc)
@@ -68,6 +68,19 @@ pub(crate) fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base
(1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string())
}
/// Add `fonts` (resource name, font) to the page's own resources.
pub(crate) fn add_page_fonts(doc: &mut Document, page: &pdfcraft_model::Page, fonts: &Dict) -> Result<(), EditError> {
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut all = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
for (name, font) in fonts.iter() {
all.set(name.clone(), font.clone());
}
res.set(b"Font".to_vec(), Object::Dict(all));
// The page gets its own (possibly copied) resources; shared dictionaries stay untouched.
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
Ok(())
}
/// Add the standard `faces` (resource name, base font) and an opacity state to the page's own
/// resources.
pub(crate) fn add_page_resources(
@@ -575,10 +588,3 @@ pub(crate) fn undrawable(c: char) -> EditError {
pub fn undrawable_message(c: char) -> String {
format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c))
}
pub(crate) fn text_op(x: f64, y: f64, text: &str) -> Vec<u8> {
let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes();
v.extend(literal(&win_ansi(text)));
v.extend_from_slice(b" Tj\n");
v
}
+100
View File
@@ -6199,6 +6199,55 @@ fn header_and_watermark_drawability_matches_what_applying_refuses() {
}
}
/// Thai in a header, footer or text watermark is drawn from the Thai face as Add text draws it:
/// Type 3 glyphs with a ToUnicode map, one set of fonts shared by every page the mark goes on,
/// beside the mark's standard face. A build without a Thai face refuses it, saying what to do.
#[test]
fn thai_headers_footers_and_watermarks_are_drawn_from_the_thai_face() {
let blank = String::new;
let footer = HeaderFooter {
text: [blank(), "หน้า <<1>> บุญฤทธิ์".into(), blank(), blank(), blank(), "น้ำ <<Bates Number#3#1#ก-#>>".into()],
..HeaderFooter::default()
};
let wm = Watermark { text: "ลับ ปั้น\nDRAFT".into(), ..Watermark::default() };
if pdfcraft_fonts::document_thai_font().is_none() {
let mut doc = fixture();
for err in [add_header_footer(&mut doc, &[0], &footer, false, &cx()).unwrap_err(), add_watermark(&mut doc, &[0], &wm, false).unwrap_err()] {
assert!(matches!(&err, EditError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err:?}");
}
assert!(!doc.is_modified(), "nothing is written when the font is missing");
assert_eq!((footer.first_undrawable(), wm.first_undrawable()), (Some('ห'), Some('ล')));
return;
}
assert_eq!((footer.first_undrawable(), wm.first_undrawable()), (None, None));
let mut doc = fixture();
add_header_footer(&mut doc, &[0, 1], &footer, false, &cx()).unwrap();
add_watermark(&mut doc, &[0, 1], &wm, false).unwrap();
let doc = reopen(&doc);
let mut shared = Vec::new();
for page in 0..2 {
let drawn = streams(&doc, page).concat();
assert!(drawn.contains("/PCTh") && drawn.contains("(DRAFT) Tj") && !drawn.contains("(?"), "{drawn}");
let p = &pdfcraft_model::pages(&doc)[page];
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone();
let thai: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCTh")).map(|(k, v)| (k.clone(), v.clone())).collect();
assert_eq!(thai.len(), 2, "the footer's fonts and the watermark's");
let mut mapped = String::new();
for (_, f) in &thai {
let f = doc.resolve(f).as_dict().unwrap().clone();
assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..]));
mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap());
}
// น้ำ, ก (the Bates prefix) and ปั้น copy as typed.
for text in ["<0E190E490E33>", "<0E01>", "<0E1B0E310E49>"] {
assert!(mapped.contains(text), "{text} in {mapped}");
}
shared.push(thai);
}
assert_eq!(shared[0], shared[1], "every page draws with the same fonts");
}
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
/// and leave the document untouched.
#[test]
@@ -6619,6 +6668,57 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() {
assert!(arabic_font_names(&doc, 0).is_empty());
}
#[test]
fn added_thai_text_wraps_between_dictionary_words() {
if pdfcraft_fonts::document_thai_font().is_none() {
eprintln!("skipping the Thai word wrapping checks: built without craft-fonts (set CRAFT_FONTS_DIR)");
return;
}
let text = "การประปาส่วนภูมิภาคให้บริการน้ำประปาที่สะอาดแก่ประชาชนทั่วประเทศ";
let t = AddedText { rect: [0.0, 0.0, 90.0, 400.0], text: text.into(), size: 16.0, ..AddedText::default() };
let rows = super::added::lines(&t);
assert!(rows.len() > 2, "{rows:?}");
assert_eq!(rows.concat(), text, "no space is added or lost between the rows");
// Every row ends where the dictionary lets one end, never inside a word such as ประปา.
let cuts = crate::linebreak::breaks(text);
let mut at = 0;
for row in rows.iter().take(rows.len() - 1) {
at += row.len();
assert!(cuts.contains(&at), "row {row:?} ends inside a word: {rows:?}");
}
// Spaces where a row ends are dropped, as for Latin text, and a word wider than the row is
// still broken between clusters.
let spaced =
AddedText { text: "น้ำประปา สะอาด ปลอดภัย".into(), rect: [0.0, 0.0, 60.0, 400.0], ..t.clone() };
let rows = super::added::lines(&spaced);
assert!(rows.iter().all(|r| !r.starts_with(' ') && !r.ends_with(' ')), "{rows:?}");
assert_eq!(rows.concat(), "น้ำประปาสะอาดปลอดภัย");
let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t.clone() };
assert_eq!(super::added::lines(&narrow), ["กิ่", "กิ่", "กิ่"]);
// Spaces that open a paragraph are kept, but they are not a row of their own: a first word
// too wide to fit after them stays on the first row (or breaks between clusters there).
for width in [16.0, 40.0, 60.0, 90.0] {
let indented = AddedText { rect: [0.0, 0.0, width, 400.0], text: " ประปาสะอาด".into(), ..t.clone() };
let rows = super::added::lines(&indented);
assert!(rows.iter().all(|r| !r.trim().is_empty()), "an empty row at width {width}: {rows:?}");
assert!(rows[0].starts_with(" "), "the opening spaces are kept at width {width}: {rows:?}");
assert_eq!(rows.concat().trim_start(), "ประปาสะอาด");
}
// A first word that fits a row alone but not after the opening spaces breaks between clusters
// on the first row rather than running past its end.
let word = "ประปา";
let word_w = super::added::shaped_width(&t, word, false);
let indent_w = super::added::shaped_width(&t, " ", false);
let width = word_w + indent_w / 2.0;
let indented = AddedText { rect: [0.0, 0.0, width, 400.0], text: format!(" {word}"), ..t.clone() };
let rows = super::added::lines(&indented);
for row in &rows {
assert!(super::added::shaped_width(&t, row, false) <= width + 0.5, "{row:?} overflows {width}: {rows:?}");
}
assert!(rows.len() > 1 && rows[0].starts_with(" "), "{rows:?}");
assert_eq!(rows.concat().trim_start(), word);
}
#[test]
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
for content in [
+13 -1
View File
@@ -63,9 +63,9 @@ pub(crate) use lines::{
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
pub use rewrite::{LineEdit, replace_line, rewrite_block};
use rewrite::{coincident_ops, rewrite_block_from};
pub(crate) use rewrite::{paragraph_ops, reveal_ops, selectable_blocks, show_starts, translate_blocks};
#[cfg(test)]
pub(crate) use rewrite::{replace_line_with_limit, rewrite_block_with_limit};
pub(crate) use rewrite::{reveal_ops, selectable_blocks, translate_blocks};
/// One line of existing text.
#[derive(Clone, Debug, PartialEq)]
@@ -277,6 +277,18 @@ mod tests {
assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]);
}
#[test]
fn thai_text_wraps_between_dictionary_words_and_keeps_marks_on_their_word() {
// Without word breaks the whole run is one word and overflows a single row.
assert_eq!(wrapped("ภาษาไทยไม่มีช่องว่าง", 8.0), ["ภาษาไทย", "ไม่มี", "ช่องว่าง"]);
// Mai Yamok stays with the word it repeats; pieces join without a space, words keep theirs.
assert_eq!(wrapped("เด็กๆชอบกิน ขนม", 6.0), ["เด็กๆ", "ชอบกิน", "ขนม"]);
assert_eq!(wrapped("ไฟล์ PDF ประปาส่วนภูมิภาค", 9.0), ["ไฟล์ PDF", "ประปาส่วน", "ภูมิภาค"]);
// A wrap between Thai words consumed no separator, so a rejoin puts back no space.
let rows = wrap("ภาษาไทยไม่มีช่องว่าง", 8.0, |s| s.chars().count() as f64);
assert_eq!(rows.iter().map(|(_, soft, gap)| (*soft, *gap)).collect::<Vec<_>>(), [(false, 0), (true, 0), (true, 0)]);
}
#[test]
fn fallback_face_moves_on_when_the_best_face_lacks_a_character() {
static JPAN: &[&str] = &["Jpan"];
+31 -11
View File
@@ -1117,6 +1117,36 @@ fn vertical_encoding(doc: &Document, font: &Dict) -> Result<bool, EditError> {
Err(EditError::Invalid("the text encoding is nested too deeply".into()))
}
/// The text-showing operators of paragraphs `indexes` (from [`group_blocks`] of the page's
/// lines), as indexes among the page's joined operators. `editable`: refuse a line that can't be
/// edited (moving rewrites its operators; copying takes them as they are).
pub(crate) fn paragraph_ops(doc: &Document, page: usize, indexes: &[usize], editable: bool) -> Result<std::collections::BTreeSet<usize>, EditError> {
let mut selected = std::collections::BTreeSet::new();
if indexes.is_empty() {
return Ok(selected);
}
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
for index in indexes {
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
for line in &b.lines {
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
if editable {
l.ensure_editable()?;
}
selected.extend(l.ops.iter().copied());
}
}
Ok(selected)
}
/// Where each text-showing operator of `ops` (a page's joined operators, drawn with `resources`)
/// starts its glyphs: the text matrix there, and its rendering mode (`Tr`).
pub(crate) fn show_starts(doc: &Document, ops: &[Op], resources: &Dict) -> Result<HashMap<usize, (Matrix, i64)>, EditError> {
let shown = interpret(doc, ops, resources, &mut HashMap::new(), &mut Carry::new(), None, None)?;
Ok(shown.into_iter().map(|s| (s.op, (s.tm, s.state.render_mode))).collect())
}
/// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing.
/// Numeric TJ offsets and text rise temporarily move each selected show operation, then
/// restore both advances and rise. The PDF reader advances the original glyph bytes itself:
@@ -1127,17 +1157,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
}
let p = page_dict(doc, page)?;
let streams = content_streams(doc, &p.dict)?;
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let mut selected = std::collections::BTreeSet::new();
for index in indexes {
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
for line in &b.lines {
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
l.ensure_editable()?;
selected.extend(l.ops.iter().copied());
}
}
let selected = paragraph_ops(doc, page, indexes, true)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = joined(&streams);
+27 -7
View File
@@ -685,18 +685,41 @@ fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 {
type WrappedStyledRows = (StyledRows, RowRanges, Vec<usize>, Vec<bool>, Vec<usize>);
/// For each of `chars`, whether a row may end just before it inside a run of non-space characters
/// (Chinese, Japanese and Thai), worked out once per run: Thai words need the whole run, not just
/// two neighbours.
fn styled_break_before(chars: &[StyledChar]) -> Vec<bool> {
let mut break_before = vec![false; chars.len()];
let mut run_start = 0;
while run_start < chars.len() {
let mut run_end = run_start;
while chars.get(run_end).is_some_and(|c| !c.0.is_whitespace()) {
run_end += 1;
}
let run: String = chars.get(run_start..run_end).unwrap_or_default().iter().map(|c| c.0).collect();
for (i, cut) in crate::linebreak::breaks_before_chars(&run).into_iter().enumerate() {
if let Some(slot) = break_before.get_mut(run_start.saturating_add(i)) {
*slot = cut;
}
}
run_start = run_end.max(run_start.saturating_add(1));
}
break_before
}
fn wrap_styled_rows(
chars: &[StyledChar],
width: f64,
measure: impl Fn(&[StyledChar]) -> Result<f64, EditError>,
) -> Result<WrappedStyledRows, EditError> {
let break_before = styled_break_before(chars);
let mut rows: Vec<Vec<StyledChar>> = Vec::new();
let mut row_ranges = Vec::new();
// True when this row starts because the previous word did not fit, not because of a newline.
let mut row_soft = Vec::new();
let mut next_soft = false;
// How many separator characters a wrap trimmed off the row that just ended, for the row
// about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless
// about to start (0 for a Chinese, Japanese or Thai word split, which trims none; meaningless unless
// the row it belongs to is soft). Mirrors `next_soft`.
let mut row_gap = Vec::new();
let mut next_gap = 0usize;
@@ -729,11 +752,8 @@ fn wrap_styled_rows(
}
let mut end = at + 1;
if !item.0.is_whitespace() {
// A word ends at a space, or where Chinese or Japanese text may break between characters.
while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end))
&& !current.0.is_whitespace()
&& !crate::linebreak::between(before.0, current.0)
{
// A word ends at a space, or where Chinese, Japanese or Thai text may break inside it.
while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) && !break_before.get(end).copied().unwrap_or(false) {
end += 1;
}
}
@@ -1411,7 +1431,7 @@ fn push_whole_paragraph_marker(block_ops: &mut Vec<Op>, marked: bool, op: &str)
/// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in
/// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of
/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores
/// separator characters it consumed (0 for a Chinese, Japanese or Thai word split), so a rejoin restores
/// what was actually there instead of guessing a single space (#2529). Neither is a paragraph
/// region, so the lines stay in one scope.
fn push_row_break_marker(block_ops: &mut Vec<Op>, row_soft: &[bool], row_gap: &[usize], row_index: usize) {
+33 -14
View File
@@ -41,11 +41,11 @@ use crate::EditError;
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"];
pub(crate) const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
/// The `/ColorSpace` entries that remap device colours painted with the resources they are in
/// (ISO 32000-2 §8.6.5.6).
const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"];
pub(crate) 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;
@@ -258,7 +258,7 @@ pub(crate) fn ungroup_ops(
// up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it
// 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)) {
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op, budget)) {
return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") });
}
let mut depth = 0usize;
@@ -505,7 +505,7 @@ fn shows_text(op: &Op) -> bool {
}
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
struct Unreadable;
pub(crate) struct Unreadable;
/// Which operand of `op` names a resource, and from which category, read as the renderer reads
/// it: operands are taken from the end, so a surplus operand before them is ignored. `Do`, `gs`,
@@ -513,7 +513,7 @@ struct Unreadable;
/// a name or an inline dictionary; `scn` and `SCN` numbers and the last name among them.
/// `Ok(None)` when `op` names no resource; [`Unreadable`] when the renderer would skip it, which
/// [`ungroup_ops`] refuses rather than guess at.
fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
pub(crate) fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
let operands = &op.operands;
let name_at = |i: Option<usize>| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_))));
let last = operands.len().checked_sub(1);
@@ -553,25 +553,44 @@ fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadabl
}
/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from
/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with.
fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. A
/// form or pattern whose content names no resource inherits nothing (a filled shape, say), so it
/// needs no renaming; its content is read within `budget`. A Type 3 font's glyphs are not read,
/// and always count.
fn inherits_resources(doc: &Document, resources: &Dict, op: &Op, budget: &mut DecodeBudget) -> bool {
let Ok(Some((cat @ (b"XObject" | b"Font" | b"Pattern"), i))) = resource_operand(op) else { return false };
let Some(Object::Name(name)) = op.operands.get(i) else { return false };
let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else {
return false;
};
let dict = match &*doc.resolve(&entry) {
Object::Stream(s) => s.dict.clone(),
Object::Dict(d) => d.clone(),
let entry = doc.resolve(&entry);
let (dict, stream) = match &*entry {
Object::Stream(s) => (&s.dict, Some(s)),
Object::Dict(d) => (d, None),
_ => return false,
};
let drawn = match cat {
b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"),
b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"),
b"XObject" => is_name(doc, dict, b"Subtype", b"Form"),
b"Font" => is_name(doc, dict, b"Subtype", b"Type3"),
// A shading pattern has no content of its own.
_ => int_of(doc, &dict, b"PatternType") == Some(1),
_ => int_of(doc, dict, b"PatternType") == Some(1),
};
drawn && dict.get(b"Resources").is_none()
drawn && dict.get(b"Resources").is_none() && (cat == b"Font" || stream.is_none_or(|s| names_resources(doc, s, budget)))
}
/// Whether content stream `s` names a resource (or can't be read within `budget` to tell): device
/// colour spaces are not resources; an inline image counts, as its colour space may be one.
fn names_resources(doc: &Document, s: &pdfcraft_cos::Stream, budget: &mut DecodeBudget) -> bool {
let Ok(data) = budget.decode_strict(doc, s) else { return true };
let parsed = parse(&data);
parsed.skipped > 0
|| parsed.ops.iter().any(|op| match resource_operand(op) {
Ok(None) => op.inline.is_some(),
Ok(Some((b"ColorSpace", i))) => {
!matches!(op.operands.get(i), Some(Object::Name(n)) if [&b"DeviceGray"[..], b"DeviceRGB", b"DeviceCMYK", b"Pattern"].contains(&n.as_slice()))
}
_ => true,
})
}
/// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts
+13
View File
@@ -457,6 +457,12 @@ pub enum Edit {
objects: Vec<ObjectTarget>,
offset: [f64; 2],
},
/// Delete mixed existing and added objects together (references from one inventory of
/// `page`), as one step: what Delete and Cut do to objects selected together.
DeleteObjects {
page: usize,
objects: Vec<ObjectTarget>,
},
/// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`),
/// rewrapped to the box's width.
EditTextBlock {
@@ -549,6 +555,13 @@ pub enum Edit {
content: AddedContent,
file: Option<(String, Arc<Vec<u8>>)>,
},
/// Edit ▸ Paste of objects copied together in Edit a PDF ([`Document::copy_objects`](crate::Document::copy_objects)), in this
/// document or another: drawn on top of `page` as they were, moved by `offset` (user space).
PasteObjects {
page: usize,
clip: Arc<crate::ObjectClip>,
offset: [f64; 2],
},
/// Apply redaction marks (all, or those on `pages`): remove what they cover for good.
ApplyRedactions {
pages: Option<Vec<usize>>,
+2 -2
View File
@@ -302,9 +302,9 @@ impl Edit {
Edit::AddText { .. } => Label::of("Add text"),
Edit::AddImage { .. } => Label::of("Add image"),
Edit::UpdateContent { .. } => Label::of("Edit content"),
Edit::DeleteContent { .. } => Label::of("Delete content"),
Edit::DeleteContent { .. } | Edit::DeleteObjects { .. } => Label::of("Delete content"),
Edit::ReplaceImage { .. } => Label::of("Replace image"),
Edit::PasteContent { .. } => Label::of("Paste"),
Edit::PasteContent { .. } | Edit::PasteObjects { .. } => Label::of("Paste"),
Edit::ApplyRedactions { .. } => Label::of("Apply redactions"),
Edit::ClearRedactions => Label::of("Remove redaction marks"),
Edit::RemoveHidden { .. } => Label::of("Remove hidden information"),
+4
View File
@@ -111,9 +111,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::MoveObjects { .. }
| Edit::DeleteObjects { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::PasteObjects { .. }
| Edit::ClearRedactions
| Edit::RemoveHidden { .. }
| Edit::ImportData { .. }
@@ -729,6 +731,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?,
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::DeleteObjects { page, objects } => crate::object_clip::delete(doc, *page, objects, |doc, edit| run_edit(doc, edit, cx))?,
Edit::PasteObjects { page, clip, offset } => crate::object_clip::paste(doc, *page, clip, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
}
+2
View File
@@ -79,6 +79,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
// Page content, resources and added content/marks only: the page tree, annotations,
// fields, links and document metadata are untouched (see `refresh_content`).
Edit::MoveObjects { .. }
| Edit::DeleteObjects { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::PrepareText { .. }
@@ -90,6 +91,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::PasteObjects { .. }
| Edit::AddHeaderFooter { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
+2
View File
@@ -36,6 +36,7 @@ pub mod js;
mod layers;
pub mod links;
pub mod model3d;
mod object_clip;
pub mod ocr;
mod open;
pub mod optimizer;
@@ -108,6 +109,7 @@ pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, T
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
pub use object_clip::{ObjectClip, ObjectClipboard};
pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message};
+211
View File
@@ -0,0 +1,211 @@
//! Edit ▸ Copy, Cut and Paste of objects selected together in Edit a PDF (#5989).
//!
//! A copy ([`ObjectClip`]) holds what the objects draw and the resources that drawing names
//! ([`pdfcraft_edit::copy_objects`]), with a snapshot of the document they came from, so it can
//! be pasted into any open document, also after the original changes or closes. Within the same
//! document, while the objects it refers to are unchanged, a paste draws them again from the same
//! fonts and images; otherwise it first copies them over ([`pdfcraft_organize::import_value`]):
//! each once, through cycles, sharing what the target already holds, and within a size limit.
use std::sync::Arc;
use pdfcraft_cos::Object;
use crate::{DocId, Document, Edit, EditError, EditableObject, ObjectTarget};
/// The most objects a paste copies into another document.
const MAX_IMPORT_OBJECTS: usize = 20_000;
/// The most stream data (fonts, images…) a paste copies into another document.
const MAX_IMPORT_BYTES: usize = 256 << 20;
/// How far each further paste onto the same page steps down and to the right, in points, as
/// pasted added content steps ([`pdfcraft_edit::added::PASTE_STEP`]).
const STEP: f64 = pdfcraft_edit::added::PASTE_STEP;
/// Objects copied from one page of a document.
#[derive(Clone)]
pub struct ObjectClip {
copied: pdfcraft_edit::CopiedObjects,
/// The document as it was when copied: what the copy's resources refer to.
source: pdfcraft_cos::Document,
doc: DocId,
page: usize,
}
impl PartialEq for ObjectClip {
fn eq(&self, other: &Self) -> bool {
self.copied == other.copied
&& self.doc == other.doc
&& self.page == other.page
&& self.source.source_identity() == other.source.source_identity()
}
}
impl std::fmt::Debug for ObjectClip {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ObjectClip").field("doc", &self.doc).field("page", &self.page).field("objects", &self.copied.objects.len()).finish()
}
}
impl ObjectClip {
/// The objects as they were listed when copied ([`Document::editable_objects`]).
pub fn objects(&self) -> &[EditableObject] {
&self.copied.objects
}
/// The text of the copied paragraphs and text boxes, one per line.
pub fn text(&self) -> String {
self.copied.text()
}
/// The union of the objects' boxes, in the user space of the page they came from.
pub fn rect(&self) -> [f64; 4] {
self.copied.rect
}
/// The document and page the objects were copied from.
pub fn source(&self) -> (DocId, usize) {
(self.doc, self.page)
}
}
impl Document {
/// Edit ▸ Copy of `targets` (references from one [`Document::editable_objects`] inventory of
/// `page`). Refused when the document doesn't allow copying. Changes nothing.
pub fn copy_objects(&self, page: usize, targets: &[ObjectTarget]) -> Result<ObjectClip, EditError> {
self.check_copying()?;
let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly("the document can't be read".into()))?;
let copied = pdfcraft_edit::copy_objects(&editor.cos, page, targets)?;
Ok(ObjectClip { copied, source: editor.cos.clone(), doc: self.id, page })
}
/// Where Paste puts `clip` on `page`: the user-space offset that moves it `steps` × 18 pt down
/// and to the right as displayed, or, when that leaves nothing of it on the page's crop box,
/// the offset that centres it there.
pub fn object_paste_offset(&self, page: usize, clip: &ObjectClip, steps: u32) -> Result<[f64; 2], String> {
let step = STEP * f64::from(steps);
let offset = self.object_move_offset(page, [step, step])?;
let p = self.info.pages.get(page).ok_or("the page no longer exists")?;
let crop = p.crop.map(f64::from);
let r = clip.rect();
let moved = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]];
if moved[0] < crop[2] && moved[2] > crop[0] && moved[1] < crop[3] && moved[3] > crop[1] {
return Ok(offset);
}
let centre = |a: f64, b: f64| (a + b) / 2.0;
Ok([centre(crop[0], crop[2]) - centre(r[0], r[2]), centre(crop[1], crop[3]) - centre(r[1], r[3])])
}
}
/// The objects copied in an application or a script, and how many times each page has had them
/// pasted, which sets how far the next paste there steps ([`Document::object_paste_offset`]).
#[derive(Clone, Debug)]
pub struct ObjectClipboard {
clip: Arc<ObjectClip>,
pastes: Vec<(DocId, usize, u32)>,
}
impl ObjectClipboard {
pub fn new(clip: ObjectClip) -> Self {
ObjectClipboard { clip: Arc::new(clip), pastes: Vec::new() }
}
pub fn clip(&self) -> &ObjectClip {
&self.clip
}
/// The edit that pastes the copy onto `page` of `doc`: where it was, or on the page it came
/// from one step down and to the right (as Acrobat offsets a paste onto the same page), and
/// one step further for each earlier paste onto that page.
pub fn paste(&mut self, doc: &Document, page: usize) -> Result<Edit, String> {
let at = self.pastes.iter().position(|(d, p, _)| *d == doc.id && *p == page);
let earlier = at.and_then(|i| self.pastes.get(i)).map_or(0, |(_, _, n)| *n);
let own = u32::from(self.clip.source() == (doc.id, page));
let offset = doc.object_paste_offset(page, &self.clip, earlier.saturating_add(own))?;
match at.and_then(|i| self.pastes.get_mut(i)) {
Some(entry) => entry.2 = entry.2.saturating_add(1),
None => self.pastes.push((doc.id, page, 1)),
}
Ok(Edit::PasteObjects { page, clip: Arc::clone(&self.clip), offset })
}
}
impl Edit {
/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step:
/// what Delete and Cut do to objects selected together. One object is deleted by its own
/// edit, several by [`Edit::DeleteObjects`]; none is no edit.
pub fn delete_objects(page: usize, mut targets: Vec<ObjectTarget>) -> Option<Edit> {
targets.sort();
targets.dedup();
match targets.as_slice() {
[] => None,
[t] => Some(delete_one(page, *t)),
_ => Some(Edit::DeleteObjects { page, objects: targets }),
}
}
}
fn delete_one(page: usize, t: ObjectTarget) -> Edit {
use crate::ObjectKind;
match t.kind {
ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: crate::BlockStyle::default() },
ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: crate::ImageEdit::Delete },
ObjectKind::Added => Edit::DeleteContent { page, index: t.index },
}
}
/// [`Edit::DeleteObjects`], each object by its own edit (`run`). A deletion renumbers what follows
/// it in its own inventory, so pictures go from the last to the first, and added content, which
/// the other inventories leave out, after them. Deleting a paragraph also removes a copy drawn
/// exactly over it (fake bold, a paste onto itself), which renumbers paragraphs before it too: so
/// each paragraph is found again by its text and box, and one already gone with another is done.
pub(crate) fn delete(
doc: &mut pdfcraft_cos::Document,
page: usize,
objects: &[ObjectTarget],
mut run: impl FnMut(&mut pdfcraft_cos::Document, &Edit) -> Result<(), EditError>,
) -> Result<(), EditError> {
use crate::ObjectKind;
let mut targets = objects.to_vec();
targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index)));
targets.dedup();
let blocks = pdfcraft_edit::text_blocks(doc, page)?;
for t in targets {
let edit = match t.kind {
ObjectKind::Text => {
let key = blocks.get(t.index).map(crate::BlockKey::of).ok_or_else(|| {
EditError::Edit(pdfcraft_edit::EditError::Invalid("a selected paragraph no longer exists; select the objects again".into()))
})?;
let Some(block) = key.find(&pdfcraft_edit::text_blocks(doc, page)?, t.index) else { continue };
delete_one(page, ObjectTarget { kind: t.kind, index: block })
}
_ => delete_one(page, t),
};
run(doc, &edit)?;
}
Ok(())
}
/// [`Edit::PasteObjects`]: the copy's resources as `doc` holds them, then the paste.
pub(crate) fn paste(doc: &mut pdfcraft_cos::Document, page: usize, clip: &ObjectClip, offset: [f64; 2]) -> Result<(), EditError> {
let resources = Object::Dict(clip.copied.resources.clone());
let refused =
|e: pdfcraft_organize::OrganizeError| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("the copied objects can't be pasted: {e}")));
let used = pdfcraft_organize::reachable(&clip.source, &resources, MAX_IMPORT_OBJECTS, MAX_IMPORT_BYTES).map_err(refused)?;
// Still the objects the copy was made from: the same document, and each of them unchanged.
let same = doc.source_identity() == clip.source.source_identity()
&& used.iter().all(|r| {
let (here, there) = (doc.get(*r), clip.source.get(*r));
Arc::ptr_eq(&here, &there) || here == there
});
let resources = if same { resources } else { pdfcraft_organize::import_value(doc, &clip.source, &resources).map_err(refused)? };
let Object::Dict(resources) = resources else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the copied objects' resources could not be copied".into())));
};
let copied = pdfcraft_edit::CopiedObjects { resources, ..clip.copied.clone() };
pdfcraft_edit::paste_objects(doc, page, &copied, offset)?;
Ok(())
}
#[cfg(test)]
#[path = "object_clip_tests.rs"]
mod tests;
+257
View File
@@ -0,0 +1,257 @@
use super::*;
use crate::{ObjectKind, Session};
/// A PDF of 300 × 300 pages, each drawing its `content` with `resources` and objects `extra`
/// (numbered after the pages' content streams).
fn pdf(pages: &[(&str, &str)], extra: &[String]) -> Arc<Vec<u8>> {
let n = pages.len();
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 300] >>", kids.join(" ")),
];
for (i, (_, resources)) in pages.iter().enumerate() {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 3 + n + i));
}
for (content, _) in pages {
objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));
}
objs.extend(extra.iter().cloned());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Arc::new(out)
}
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
const COURIER: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>";
/// A 2 × 2 RGB picture of the twelve bytes `data` (ASCII, so the file's lengths are its bytes).
fn picture(data: &str) -> String {
assert!(data.is_ascii() && data.len() == 12);
format!(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\n{data}\nendstream"
)
}
/// A one-page document whose paragraph "Copied" (blue, half opaque, Helvetica /F1 at objects 5)
/// and picture /Im0 (object 6) are copied; "Left" is not.
fn source() -> Arc<Vec<u8>> {
let content = "0 0 1 rg /GS0 gs BT /F1 24 Tf 20 200 Td (Copied) Tj ET q 60 0 0 40 20 100 cm /Im0 Do Q BT /F1 12 Tf 200 40 Td (Left) Tj ET";
pdf(
&[(content, "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >>")],
&[HELVETICA.into(), picture("\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}")],
)
}
/// A one-page document that already draws with Courier as /F1 and has a picture of its own as /Im0.
fn target() -> Arc<Vec<u8>> {
pdf(
&[("BT /F1 10 Tf 250 280 Td (x) Tj ET q 10 0 0 10 280 5 cm /Im0 Do Q", "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >>")],
&[COURIER.into(), picture("@@@@@@@@@@@@")],
)
}
fn copy_all_but_left(s: &Session, id: DocId) -> ObjectClip {
let doc = s.get(id).unwrap();
let targets: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter(|o| o.text.as_deref() != Some("Left")).map(|o| o.target).collect();
assert_eq!(targets.len(), 2);
doc.copy_objects(0, &targets).unwrap()
}
fn render(s: &Session, id: DocId) -> pdfcraft_render::RenderedPage {
let doc = s.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
}
/// The pixels in user-space box `r` of a 300 pt page rendered at scale 1.
fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4]) -> Vec<u8> {
let mut out = Vec::new();
for y in (300.0 - r[3]) as u32..(300.0 - r[1]) as u32 {
for x in r[0] as u32..r[2] as u32 {
let i = ((y * page.width + x) * 4) as usize;
out.extend_from_slice(&page.rgba[i..i + 4]);
}
}
out
}
fn images(s: &Session, id: DocId) -> Vec<Option<pdfcraft_cos::ObjRef>> {
s.get(id).unwrap().page_images(0).into_iter().map(|i| i.object).collect()
}
#[test]
fn objects_copied_from_one_document_paste_into_another_and_draw_the_same() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let b = s.open("b.pdf", None, target(), None).unwrap();
let clip = copy_all_but_left(&s, a);
assert_eq!(clip.text(), "Copied");
let mut clipboard = ObjectClipboard::new(clip);
// The source can change, or close: the copy is a snapshot.
s.apply(a, Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Image, index: 0 }]).unwrap()).unwrap();
s.close(a);
let a = s.open("a.pdf", None, source(), None).unwrap();
let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap();
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [0.0, 0.0]), "another document: where it was");
s.apply(b, paste).unwrap();
assert_eq!(s.get(b).unwrap().can_undo(), Some("Paste"));
let area = clipboard.clip().rect().map(|v| v.round());
let area = [area[0] - 3.0, area[1] - 3.0, area[2] + 3.0, area[3] + 3.0];
let (original, pasted) = (pixels(&render(&s, a), area), pixels(&render(&s, b), area));
assert!(original.chunks(4).any(|p| p[2] > 200 && p[0] < 200), "the blue paragraph is drawn");
assert_eq!(original, pasted, "the pasted paragraph and picture draw the same pixels");
// Its own /F1 and /Im0 are left alone; what the copy uses came over under other names.
let doc = s.get(b).unwrap();
let texts: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect();
assert!(texts.contains(&"Copied".to_string()) && !texts.contains(&"Left".to_string()), "{texts:?}");
let pictures = images(&s, b);
assert_eq!(pictures.len(), 2);
assert_ne!(pictures[0], pictures[1]);
// A second paste steps down and to the right, and shares the fonts and pictures the first
// brought instead of copying them again.
let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap();
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "{paste:?}");
s.apply(b, paste).unwrap();
let pictures = images(&s, b);
assert_eq!(pictures.len(), 3);
assert_eq!(pictures[1], pictures[2], "one picture object for both pastes");
// Saved and reopened, the pastes are there; undone, the page is as it was.
let saved = s.save_bytes(b).unwrap();
let mut again = Session::new();
let reopened = again.open("b.pdf", None, saved, None).unwrap();
assert_eq!(again.get(reopened).unwrap().page_images(0).len(), 3);
s.undo(b).unwrap();
s.undo(b).unwrap();
assert_eq!(images(&s, b).len(), 1);
assert_eq!(s.get(b).unwrap().can_undo(), None);
}
#[test]
fn a_paste_onto_the_page_it_came_from_steps_and_draws_from_the_same_objects() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a));
let paste = clipboard.paste(s.get(a).unwrap(), 0).unwrap();
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "one step down and right: {paste:?}");
s.apply(a, paste).unwrap();
let pictures = images(&s, a);
assert_eq!(pictures.len(), 2);
assert_eq!(pictures[0], pictures[1], "the same picture object, not a copy");
// Far off the page (a copy from a larger page), a paste is centred instead.
let doc = s.get(a).unwrap();
let mut far = clipboard.clip().clone();
far.copied.rect = [1000.0, 1000.0, 1100.0, 1050.0];
assert_eq!(doc.object_paste_offset(0, &far, 0).unwrap(), [150.0 - 1050.0, 150.0 - 1025.0]);
}
#[test]
fn pasting_into_another_document_follows_cycles_and_refuses_past_its_limit() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
// A font that refers to itself and to an object that refers back.
let cyclic = pdf(
&[("BT /F1 20 Tf 20 200 Td (Loop) Tj ET", "<< /Font << /F1 5 0 R >> >>")],
&[
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Self 5 0 R /Next [6 0 R] >>".into(),
"<< /Back 5 0 R /Again 6 0 R >>".into(),
],
);
let a = s.open("cyclic.pdf", None, cyclic, None).unwrap();
let b = s.open("b.pdf", None, target(), None).unwrap();
let all: Vec<_> = s.get(a).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect();
let mut clipboard = ObjectClipboard::new(s.get(a).unwrap().copy_objects(0, &all).unwrap());
s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap();
assert!(s.get(b).unwrap().editable_objects(0).unwrap().iter().any(|o| o.text.as_deref() == Some("Loop")));
// An ExtGState that drags in more objects than a paste copies is refused with a reason.
let many = 20_001;
let refs: Vec<String> = (0..many).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut extra = vec![format!("<< /ca 0.5 /Junk [{}] >>", refs.join(" "))];
extra.extend((0..many).map(|i| format!("<< /N {i} >>")));
let resources = format!("<< /Font << /F1 {HELVETICA} >> /ExtGState << /GS0 5 0 R >> >>");
let big = pdf(&[("/GS0 gs BT /F1 20 Tf 20 200 Td (Big) Tj ET", &resources)], &extra);
let c = s.open("big.pdf", None, big, None).unwrap();
let all: Vec<_> = s.get(c).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect();
assert_eq!(all.len(), 1);
let mut clipboard = ObjectClipboard::new(s.get(c).unwrap().copy_objects(0, &all).unwrap());
let before = s.get(b).unwrap().can_undo().map(str::to_string);
let err = s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap_err().to_string();
assert!(err.contains("copy fewer objects"), "{err}");
assert_eq!(s.get(b).unwrap().can_undo().map(str::to_string), before, "a refused paste changes nothing");
}
#[test]
fn copying_refuses_a_stale_selection_and_deleting_several_objects_is_one_step() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let doc = s.get(a).unwrap();
assert!(doc.copy_objects(0, &[ObjectTarget { kind: ObjectKind::Text, index: 7 }]).is_err());
assert!(doc.copy_objects(3, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }]).is_err());
let delete = Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Text, index: 0 }, ObjectTarget { kind: ObjectKind::Image, index: 0 }]);
assert!(matches!(delete, Some(Edit::DeleteObjects { .. })));
assert!(Edit::delete_objects(0, Vec::new()).is_none());
s.apply(a, delete.unwrap()).unwrap();
assert_eq!(s.get(a).unwrap().can_undo(), Some("Delete content"));
assert!(s.get(a).unwrap().editable_objects(0).unwrap().iter().all(|o| o.text.as_deref() == Some("Left")));
}
/// Two pastes of a paragraph and a picture exactly over each other: deleting a paragraph also
/// removes a copy drawn exactly over it, so deleting all four objects together found the second
/// paragraph's number gone and refused the whole delete. Both copies go, in one step.
#[test]
fn objects_pasted_exactly_over_each_other_delete_together_in_one_step() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let blank = s.create_blank(300.0, 300.0, 1).unwrap();
let b = s.open_new("b.pdf", blank).unwrap();
let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a));
let Edit::PasteObjects { clip, .. } = clipboard.paste(s.get(b).unwrap(), 0).unwrap() else { panic!("a paste") };
for _ in 0..2 {
s.apply(b, Edit::PasteObjects { page: 0, clip: Arc::clone(&clip), offset: [0.0, 0.0] }).unwrap();
}
let pasted = s.get(b).unwrap().editable_objects(0).unwrap();
assert_eq!(pasted.iter().filter(|o| o.text.as_deref() == Some("Copied")).count(), 2, "{pasted:?}");
assert_eq!(pasted.len(), 4);
let delete = Edit::delete_objects(0, pasted.iter().map(|o| o.target).collect()).unwrap();
s.apply(b, delete).unwrap();
assert!(s.get(b).unwrap().editable_objects(0).unwrap().is_empty());
assert_eq!(s.get(b).unwrap().can_undo(), Some("Delete content"));
s.undo(b).unwrap();
assert_eq!(s.get(b).unwrap().editable_objects(0).unwrap(), pasted, "one undo brings both copies back");
}
/// Deleting renumbers what follows in the same inventory, so paragraphs and pictures go from
/// the last to the first, and added content (outside those inventories) after them.
#[test]
fn several_objects_delete_last_first() {
let content =
"BT /F1 12 Tf 20 250 Td (A) Tj ET BT /F1 12 Tf 20 180 Td (B) Tj ET BT /F1 12 Tf 20 110 Td (C) Tj ET BT /F1 12 Tf 20 40 Td (D) Tj ET";
let mut doc = pdfcraft_cos::Document::open(pdf(&[(content, "<< /Font << /F1 5 0 R >> >>")], &[HELVETICA.into()])).unwrap();
let t = |kind, index| ObjectTarget { kind, index };
let targets = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)];
let mut order = Vec::new();
delete(&mut doc, 0, &targets, |_, e| {
order.push(match e {
Edit::EditTextBlock { block, text, .. } if text.is_empty() => ('t', *block),
Edit::EditPageImage { index, change: crate::ImageEdit::Delete, .. } => ('i', *index),
Edit::DeleteContent { index, .. } => ('a', *index),
other => panic!("unexpected {other:?}"),
});
Ok(())
})
.unwrap();
assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]);
}
+1 -1
View File
@@ -434,7 +434,7 @@ impl Session {
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::EditPageImage { .. }
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
});
let touches_marks = edit.is_none_or(|e| {
matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. })
+5 -2
View File
@@ -56,9 +56,12 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
editor can use the first one that has all of the replacement's glyphs (the faces differ in
coverage, e.g. of Cyrillic).
- `ui_thai_fonts` / `document_thai_font` / `shape_thai`: the `Thai` faces for the interface
and shaped Thai Add Text content. Harfrust positions stacked vowel/tone marks and decomposes
and shaped Thai content. Harfrust positions stacked vowel/tone marks and decomposes
Sara Am; cluster ToUnicode entries preserve the original text. The shared Type 3 writer
embeds outlines, so reopening does not depend on installed fonts.
(`shaped_text`) embeds outlines, so reopening does not depend on installed fonts: Add text,
text boxes, Fill & Sign text, form fields, headers, footers and watermarks all write with it
(`shaped_text::appearance_lines` for appearances, whose glyph streams `store_streams` moves into
the document). `wrap_with` never breaks a line before a Thai mark.
- `craft_glyph(face, ch)` and `face_has(bytes, ch)`: bounded outlines and glyph coverage for any bundled face.
- `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`.
- `TextPlan::for_words`: how the OCR output writes text beyond WinAnsi — per word the first
+25 -6
View File
@@ -16,6 +16,7 @@ pub mod fallback;
pub mod pdf;
pub mod plan;
mod script;
pub mod shaped_text;
mod shaping;
pub mod source_shaping;
mod std14;
@@ -99,7 +100,6 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec<String> {
let mut lines = Vec::new();
let space = measure(" ", size);
let mut buf = [0u8; 4];
// A CRLF pair is one break, a lone CR or LF is one break each.
for para in text.replace("\r\n", "\n").split(['\n', '\r']) {
let mut line = String::new();
@@ -125,14 +125,24 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64)
lines.push(std::mem::take(&mut line));
}
line_width = 0.0;
for ch in word.chars() {
let ch_width = measure(ch.encode_utf8(&mut buf), size);
if !line.is_empty() && line_width + ch_width > width {
// Broken between characters, but never before a Thai mark: it stays on its letter.
let mut start = 0;
let mut units = Vec::new();
for (i, ch) in word.char_indices().skip(1) {
if !shaped_text::joins_previous(ch) {
units.extend(word.get(start..i));
start = i;
}
}
units.extend(word.get(start..).filter(|u| !u.is_empty()));
for unit in units {
let unit_width = measure(unit, size);
if !line.is_empty() && line_width + unit_width > width {
lines.push(std::mem::take(&mut line));
line_width = 0.0;
}
line.push(ch);
line_width += ch_width;
line.push_str(unit);
line_width += unit_width;
}
fresh = line.is_empty();
}
@@ -320,6 +330,15 @@ mod tests {
assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0));
}
#[test]
fn a_broken_thai_word_keeps_its_marks_on_their_letters() {
// Every character one point wide: "น้ำ" (three characters) is one unit, never split.
let lines = wrap_with("น้ำปั้นบุญฤทธิ์", 1.0, 2.0, |s, _| s.chars().count() as f64);
assert_eq!(lines.concat(), "น้ำปั้นบุญฤทธิ์");
assert_eq!(lines, ["น้ำ", "ปั้", "น", "บุ", "ญฤ", "ท", "ธิ์"]);
assert!(lines.iter().all(|l| l.chars().next().is_some_and(|c| !shaped_text::joins_previous(c))), "{lines:?}");
}
#[test]
fn crlf_is_one_line_break() {
assert_eq!(wrap("a\r\nb", 12.0, 100.0), ["a", "b"]);
+515
View File
@@ -0,0 +1,515 @@
//! Shaped text written into a PDF: each distinct shaped cluster (a letter with its marks, and the
//! text it stands for) becomes one glyph of a Type 3 font drawn from the craft-fonts face's
//! outlines, with a `/ToUnicode` map so copy and search get each character once. No font file is
//! embedded. Add text writes Arabic, Hebrew and Thai with it; text boxes, Fill & Sign text, form
//! fields, headers, footers and watermarks write Thai with it, beside the standard font that
//! draws the rest ([`thai_pieces`]).
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
use crate::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, literal, shape_thai, shaped_glyph};
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. Text gets one such font per
/// 240 distinct clusters it needs.
pub const GLYPHS_PER_FONT: usize = 240;
/// The Thai block (U+0E00–U+0E7F): letters, vowels, tone marks, digits and signs.
pub fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
}
/// Thai vowel signs and tone marks drawn on the consonant before them, and Sara Am, which the
/// face splits over it: a line never breaks before one, so a mark stays on its letter.
pub fn joins_previous(c: char) -> bool {
matches!(u32::from(c), 0x0E31 | 0x0E33..=0x0E3A | 0x0E47..=0x0E4E)
}
/// Why Thai text can't be drawn, for a message an agent or user can act on.
pub fn thai_message(e: GlyphError, text: &str) -> String {
match e {
GlyphError::NoFont => "Thai text needs a Thai font, and this build has none; rebuild with CRAFT_FONTS_DIR pointing to a craft-fonts \
checkout with a Thai face"
.into(),
GlyphError::Missing => format!("the Thai font can't show \"{text}\""),
GlyphError::TooComplex => "the Thai glyphs are too complex; use shorter text".into(),
}
}
/// A piece of a line of shaped text, in drawing order.
#[derive(Clone, Debug, PartialEq)]
pub enum Piece {
/// Shown in the text's own standard font.
Latin(String),
Shaped(Vec<ShapedCluster>),
}
/// `line` split into its Thai runs, shaped with the Thai face, and the text between them, left to
/// the standard font. Thai runs left to right, so the pieces are in typing order.
/// [`GlyphError::NoFont`] when there's Thai and this build has no Thai face.
pub fn thai_pieces(line: &str) -> Result<Vec<Piece>, GlyphError> {
let mut runs: Vec<(bool, String)> = Vec::new();
for c in line.chars() {
match runs.last_mut() {
Some((thai, s)) if *thai == is_thai(c) => s.push(c),
_ => runs.push((is_thai(c), c.to_string())),
}
}
runs.into_iter().map(|(thai, s)| if thai { shape_thai(&s).map(Piece::Shaped) } else { Ok(Piece::Latin(s)) }).collect()
}
/// The width of `text` at `size`: its Thai shaped, the rest measured by `latin`. Text the Thai
/// face can't shape is measured by `latin` as a whole (drawing then reports why).
pub fn thai_width(text: &str, size: f64, latin: impl Fn(&str, f64) -> f64) -> f64 {
if !text.chars().any(is_thai) {
return latin(text, size);
}
match thai_pieces(text) {
Ok(pieces) => pieces_width(&pieces, size, 0.0, latin),
Err(_) => latin(text, size),
}
}
/// The width of `pieces` at `size`, with `spacing` after each shaped cluster (as `Tc` moves the
/// pen; the caller measures it into `latin`'s widths).
pub fn pieces_width(pieces: &[Piece], size: f64, spacing: f64, latin: impl Fn(&str, f64) -> f64) -> f64 {
pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => latin(s, size),
// Each cluster is shown on its own, so the spacing follows each once.
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * size + spacing).sum::<f64>(),
})
.sum()
}
/// The shaped clusters of `lines`' pieces, for [`ShapedFonts::new`].
pub fn clusters<'a>(lines: impl IntoIterator<Item = &'a [Piece]>) -> impl Iterator<Item = &'a ShapedCluster> {
lines.into_iter().flatten().flat_map(|p| match p {
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
})
}
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
/// equal clusters come from the same font units).
type ClusterKey = (ShapedScript, String, Vec<(u32, u64, u64)>, u64);
fn cluster_key(c: &ShapedCluster) -> ClusterKey {
(c.script, c.text.clone(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
}
/// The Type 3 fonts that draw a set of shaped clusters, one glyph per distinct cluster. The
/// default has none (for text with nothing shaped).
#[derive(Default)]
pub struct ShapedFonts {
index: HashMap<ClusterKey, usize>,
/// Each font's resource name.
names: Vec<String>,
}
impl ShapedFonts {
/// Build the fonts for `clusters` into `fonts`, under names starting `prefix` that neither
/// `taken` (the page's fonts) nor `fonts` already has. `outline` gives a glyph's outline in its
/// script's face (the craft-fonts faces, or a stand-in in tests). `store` keeps each new
/// object and returns what refers to it: `doc.add` as a reference, or the object itself for
/// an appearance that isn't in a document yet, whose streams [`store_streams`] then moves in.
///
/// Every outline is read before anything is stored, so a failure (and the cluster it came
/// from) leaves the document as it was.
pub fn new<'a>(
clusters: impl IntoIterator<Item = &'a ShapedCluster>,
outline: impl Fn(ShapedScript, u32) -> Result<GlyphOutline, GlyphError>,
taken: &Dict,
fonts: &mut Dict,
prefix: &str,
store: &mut dyn FnMut(Object) -> Object,
) -> Result<Self, (GlyphError, &'a ShapedCluster)> {
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
for c in clusters {
index.entry(cluster_key(c)).or_insert_with(|| {
keys.push(c);
keys.len() - 1
});
}
let mut outlines = HashMap::new();
for c in &keys {
for (id, _) in &c.glyphs {
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
entry.insert(outline(c.script, *id).map_err(|e| (e, *c))?);
}
}
}
// A name no font on the page has, so items never replace each other's fonts (object
// numbers alone don't do: a full save renumbers objects but keeps resource names).
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
let mut names = Vec::new();
for (i, chunk) in keys.chunks(GLYPHS_PER_FONT).enumerate() {
let font = type3_font(chunk, &outlines, store);
let font = store(Object::Dict(font));
let number = font.as_ref().map_or(i + 1, |r| r.num as usize);
let mut name = format!("{prefix}{number}");
let mut suffix = 0usize;
while taken_names.contains(name.as_bytes()) {
suffix = suffix.saturating_add(1);
name = format!("{prefix}{number}_{suffix}");
}
taken_names.insert(name.clone().into_bytes());
fonts.set(name.clone().into_bytes(), font);
names.push(name);
}
Ok(ShapedFonts { index, names })
}
/// The font resource name and code that show `c`, if it was one of the clusters.
pub fn code(&self, c: &ShapedCluster) -> Option<(&str, u8)> {
let &k = self.index.get(&cluster_key(c))?;
let name = self.names.get(k / GLYPHS_PER_FONT)?;
// Codes start at 1; k % 240 + 1 is at most 240.
Some((name.as_str(), u8::try_from(k % GLYPHS_PER_FONT + 1).ok()?))
}
/// Show `clusters` inside `BT … ET` from `pen` on baseline `y` at `size`, each placed with its
/// own `Tm`, `spacing` after each and `word_spacing` more after a space. `current` is the
/// shaped font the text state has selected (`None` after another font); returns the pen
/// after the clusters.
#[allow(clippy::too_many_arguments)]
pub fn show(
&self,
out: &mut Vec<u8>,
clusters: &[ShapedCluster],
mut pen: f64,
y: f64,
size: f64,
spacing: f64,
word_spacing: f64,
current: &mut Option<usize>,
) -> f64 {
for c in clusters {
let Some(&k) = self.index.get(&cluster_key(c)) else { continue };
let Some(name) = self.names.get(k / GLYPHS_PER_FONT) else { continue };
let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1);
if *current != Some(k / GLYPHS_PER_FONT) {
out.extend(format!("/{name} {} Tf ", n(size)).bytes());
*current = Some(k / GLYPHS_PER_FONT);
}
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
out.extend(literal(&[code]));
out.extend_from_slice(b" Tj\n");
pen += c.advance * size + spacing + if c.text == " " { word_spacing } else { 0.0 };
}
pen
}
/// Show a line's `pieces` inside `BT … ET` from `x` on baseline `y` at `size`: shaped clusters
/// in their Type 3 fonts, the rest in the font `latin` (which the text state has selected when
/// `current` is `None`), written by `encode` and `measure`d. `current` is as for [`Self::show`].
#[allow(clippy::too_many_arguments)]
pub fn show_line(
&self,
out: &mut Vec<u8>,
pieces: &[Piece],
x: f64,
y: f64,
size: f64,
latin: &str,
encode: impl Fn(&str) -> Vec<u8>,
measure: impl Fn(&str) -> f64,
current: &mut Option<usize>,
) {
let mut pen = x;
for p in pieces {
match p {
Piece::Latin(s) => {
if current.take().is_some() {
out.extend(format!("/{latin} {} Tf ", n(size)).bytes());
}
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
out.extend(literal(&encode(s)));
out.extend_from_slice(b" Tj\n");
pen += measure(s);
}
Piece::Shaped(clusters) => pen = self.show(out, clusters, pen, y, size, 0.0, 0.0, current),
}
}
}
}
/// `lines` as a generated appearance not yet in a document draws them: each line's pieces, its
/// Thai shaped, and the Type 3 fonts for that Thai built into `fonts` (the form's font resources)
/// as direct objects, whose streams [`store_streams`] moves into the document with the
/// appearance. Thai the face can't draw, and every line when a glyph can't be read, is left to the
/// standard font, whose encoder writes `?` for it; [`first_undrawable`] reports that.
pub fn appearance_lines(lines: &[String], fonts: &mut Dict) -> (Vec<Vec<Piece>>, ShapedFonts) {
let plain = |l: &String| vec![Piece::Latin(l.clone())];
let pieces: Vec<Vec<Piece>> =
lines.iter().map(|l| if l.chars().any(is_thai) { thai_pieces(l).unwrap_or_else(|_| plain(l)) } else { plain(l) }).collect();
match ShapedFonts::new(clusters(pieces.iter().map(Vec::as_slice)), shaped_glyph, &Dict::new(), fonts, "PCTh", &mut |o| o).map_err(|(e, _)| e) {
Ok(shaped) => (pieces, shaped),
Err(_) => (lines.iter().map(plain).collect(), ShapedFonts::default()),
}
}
/// How far the Thai glyphs of `line` reach below and above its baseline, in em units; `None`
/// without Thai, or with Thai the face can't draw.
pub fn thai_reach(line: &str) -> Option<[f64; 2]> {
if !line.chars().any(is_thai) {
return None;
}
let pieces = thai_pieces(line).ok()?;
let mut reach: Option<[f64; 2]> = None;
for c in clusters([pieces.as_slice()]) {
for (id, [_, dy]) in &c.glyphs {
let g = shaped_glyph(c.script, *id).ok()?;
if !g.contours.is_empty() {
let b = [g.bbox[1] + dy, g.bbox[3] + dy];
reach = Some(reach.map_or(b, |r| [r[0].min(b[0]), r[1].max(b[1])]));
}
}
}
reach
}
/// The first character of `s` a generated appearance would draw as `?`: one the standard fonts
/// (WinAnsiEncoding) can't draw and that isn't Thai, or Thai on a line the Thai face can't draw
/// (this build has none, or it lacks a character). Line breaks don't count.
pub fn first_undrawable(s: &str) -> Option<char> {
s.split(['\n', '\r']).find_map(first_undrawable_in_line)
}
/// [`first_undrawable`] for one line drawn as it is: a line break in it counts.
pub fn first_undrawable_in_line(line: &str) -> Option<char> {
let thai_fails = line.chars().any(is_thai) && thai_pieces(line).is_err();
line.chars().find(|c| if is_thai(*c) { thai_fails } else { !crate::win_ansi_encodable(*c) })
}
/// Why the Thai in `text` can't be drawn ([`thai_message`]): this build has no Thai face, or the
/// face lacks a character. For tools that refuse such text rather than write `?`; `None` when all
/// of its Thai draws, or it has none.
pub fn thai_refusal(text: &str) -> Option<String> {
text.split(['\n', '\r']).filter(|l| l.chars().any(is_thai)).find_map(|l| thai_pieces(l).err().map(|e| thai_message(e, l)))
}
/// [`type3_font`] written into `doc`, with its glyphs, descriptor and ToUnicode as objects of their
/// own: for content that draws `clusters` (at most [`GLYPHS_PER_FONT`]) as codes 1… directly.
pub fn add_type3_font(
doc: &mut Document,
clusters: &[&ShapedCluster],
outlines: &HashMap<(ShapedScript, u32), GlyphOutline>,
) -> pdfcraft_cos::ObjRef {
let font = type3_font(clusters, outlines, &mut |o| Object::Ref(doc.add(o)));
doc.add(Object::Dict(font))
}
/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`.
fn type3_font(clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>, store: &mut dyn FnMut(Object) -> Object) -> Dict {
let mut charprocs = Dict::new();
let mut differences = vec![Object::Int(1)];
let mut widths = Vec::with_capacity(clusters.len());
let mut bbox = [0.0f64; 4];
let mut to_unicode = Vec::new();
for (i, c) in clusters.iter().enumerate() {
let code = i + 1;
// Glyph space is 1000 units per em.
let mut paths = Vec::new();
let mut ink: Option<[f64; 4]> = None;
for (id, [dx, dy]) in &c.glyphs {
let Some(g) = outlines.get(&(c.script, *id)) else { continue };
for contour in &g.contours {
for (j, p) in contour.iter().enumerate() {
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
}
paths.extend_from_slice(b"h\n");
let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy];
ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]));
}
}
// Rounded outwards so no ink is clipped.
let [x0, y0, x1, y1] =
ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]);
bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)];
let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes();
if !paths.is_empty() {
proc.extend(paths);
proc.extend_from_slice(b"f\n");
}
let glyph = format!("g{code:02X}");
charprocs.set(glyph.clone().into_bytes(), store(Object::Stream(Stream::flate(Dict::new(), &proc))));
differences.push(Object::name(&glyph));
widths.push(Object::Real((c.advance * 1000.0).round()));
if !c.text.is_empty() {
let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
to_unicode.push(format!("<{code:02X}> <{hex}>\n"));
}
}
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
// A CMap block holds at most 100 entries.
for block in to_unicode.chunks(100) {
cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat()));
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let cmap = store(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
// The face's family when every glyph comes from one script; without the face, the script's
// name (as Arabic items were always named).
let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) {
Some(script) => script.font().map_or(
match script {
ShapedScript::Arabic => "Arabic",
ShapedScript::Hebrew => "Hebrew",
ShapedScript::Thai => "Thai",
},
|f| f.family,
),
None => "PdfCraft Shaped Text",
};
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec())));
descriptor.set(b"Flags".to_vec(), Object::Int(4));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"FontDescriptor".to_vec(), store(Object::Dict(descriptor)));
font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect()));
font.set(
b"FontMatrix".to_vec(),
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
);
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(widths));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
font.set(b"ToUnicode".to_vec(), cmap);
font
}
/// Most levels of dictionaries and arrays [`store_streams`] looks into: a Type 3 font in a form's
/// resources is four deep (`/Font` → font → `/CharProcs` → glyph).
const STREAM_DEPTH: usize = 8;
/// Move the streams written into `d` (the fonts [`ShapedFonts::new`] built for an appearance not
/// yet in a document) into `doc` as objects of their own, leaving references: a stream is never a
/// direct object (ISO 32000-2 §7.3.8).
pub fn store_streams(doc: &mut Document, d: &mut Dict) {
fn walk(doc: &mut Document, o: &mut Object, depth: usize) {
if depth > STREAM_DEPTH {
return;
}
match o {
Object::Stream(_) => {
let Object::Stream(mut s) = std::mem::replace(o, Object::Null) else { return };
walk_dict(doc, &mut s.dict, depth + 1);
*o = Object::Ref(doc.add(Object::Stream(s)));
}
Object::Dict(d) => walk_dict(doc, d, depth + 1),
Object::Array(a) => a.iter_mut().for_each(|o| walk(doc, o, depth + 1)),
_ => {}
}
}
fn walk_dict(doc: &mut Document, d: &mut Dict, depth: usize) {
let keys: Vec<Vec<u8>> =
d.iter().filter(|(_, v)| matches!(v, Object::Stream(_) | Object::Dict(_) | Object::Array(_))).map(|(k, _)| k.clone()).collect();
for k in keys {
if let Some(v) = d.get_mut(&k) {
walk(doc, v, depth);
}
}
}
walk_dict(doc, d, 0);
}
/// Format a number for content streams (up to three decimals).
fn n(v: f64) -> String {
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
#[cfg(test)]
mod tests {
use super::*;
fn boxed(width: f64) -> GlyphOutline {
GlyphOutline { contours: vec![vec![[0.0, -0.4], [width, -0.4], [width, 1.1], [0.0, 1.1]]], width, bbox: [0.0, -0.4, width, 1.1] }
}
fn cluster(text: &str, id: u32) -> ShapedCluster {
ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: 0.5, text: text.into() }
}
#[test]
fn appearance_fonts_keep_their_streams_until_stored_then_refer_to_them() {
let clusters = [cluster("น้ำ", 1), cluster("ก", 2), cluster("น้ำ", 1)];
let mut fonts = Dict::new();
let made = ShapedFonts::new(&clusters, |_, _| Ok(boxed(0.5)), &Dict::new(), &mut fonts, "PCTh", &mut |o| o).unwrap();
assert_eq!(made.code(&clusters[0]), Some(("PCTh1", 1)));
assert_eq!(made.code(&clusters[1]), Some(("PCTh1", 2)), "a repeated cluster is one glyph");
let mut line = Vec::new();
let pieces = [Piece::Latin("a".into()), Piece::Shaped(clusters[..1].to_vec()), Piece::Latin("b".into())];
made.show_line(&mut line, &pieces, 0.0, 0.0, 10.0, "Helv", |s| s.as_bytes().to_vec(), |_| 1.0, &mut None);
let shown = String::from_utf8_lossy(&line).into_owned();
assert_eq!(shown, "1 0 0 1 0 0 Tm (a) Tj\n/PCTh1 10 Tf 1 0 0 1 1 0 Tm (\u{1}) Tj\n/Helv 10 Tf 1 0 0 1 6 0 Tm (b) Tj\n");
let mut out = Vec::new();
let mut current = None;
let pen = made.show(&mut out, &clusters, 10.0, 20.0, 10.0, 0.0, 0.0, &mut current);
assert_eq!(pen, 25.0);
assert_eq!(String::from_utf8_lossy(&out).matches(" Tf").count(), 1, "one font selection for one font");
let mut doc = Document::new_empty();
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
store_streams(&mut doc, &mut res);
let font = res.get(b"Font").and_then(Object::as_dict).and_then(|f| f.get(b"PCTh1")).and_then(Object::as_dict).unwrap();
assert!(matches!(font.get(b"ToUnicode"), Some(Object::Ref(_))));
let procs = font.get(b"CharProcs").and_then(Object::as_dict).unwrap();
assert!(procs.iter().count() == 2 && procs.iter().all(|(_, v)| matches!(v, Object::Ref(_))));
}
#[test]
fn a_missing_glyph_names_its_cluster_and_writes_nothing() {
let clusters = [cluster("ก", 2)];
let mut fonts = Dict::new();
let mut stored = 0;
let made = ShapedFonts::new(&clusters, |_, _| Err(GlyphError::Missing), &Dict::new(), &mut fonts, "PCTh", &mut |o| {
stored += 1;
o
});
assert!(matches!(made, Err((GlyphError::Missing, c)) if c.text == "ก"));
assert_eq!((stored, fonts.iter().count()), (0, 0));
}
#[test]
fn thai_runs_are_shaped_and_the_rest_left_to_the_standard_font() {
assert_eq!(thai_pieces("Hello").unwrap(), [Piece::Latin("Hello".into())]);
assert!(joins_previous('\u{0E49}') && joins_previous('\u{0E33}') && !joins_previous('ก') && !joins_previous('a'));
assert_eq!((first_undrawable("Café\nпривет"), thai_refusal("Café"), thai_reach("Hg")), (Some('п'), None, None));
if crate::document_thai_font().is_none() {
assert_eq!(thai_pieces("น้ำ 1"), Err(GlyphError::NoFont));
assert!(thai_message(GlyphError::NoFont, "").contains("this build has none"));
assert_eq!(thai_width("น้ำ", 10.0, |s, size| s.chars().count() as f64 * size), 30.0, "measured by the standard font");
assert_eq!(first_undrawable("ok\nชื่อ น้ำ"), Some('ช'));
assert!(thai_refusal("ok\nน้ำ").is_some_and(|m| m.contains("CRAFT_FONTS_DIR")));
return;
}
assert_eq!((first_undrawable("ชื่อ: น้ำ\nOK"), thai_refusal("ชื่อ: น้ำ")), (None, None));
assert_eq!(first_undrawable("น้ำ ☃"), Some('☃'));
// Stacked marks reach above the em box's 0.9, a descender below its 0.3.
let [_, top] = thai_reach("น้ำ").unwrap();
let [bottom, _] = thai_reach("ฎู").unwrap();
assert!(top > 0.95 && bottom < -0.35, "{top} {bottom}");
let pieces = thai_pieces("ชื่อ: บุญฤทธิ์ 12").unwrap();
assert!(matches!(&pieces[..], [Piece::Shaped(_), Piece::Latin(a), Piece::Shaped(_), Piece::Latin(b)] if a == ": " && b == " 12"));
let text: String = clusters([pieces.as_slice()]).map(|c| c.text.as_str()).collect();
assert_eq!(text, "ชื่อบุญฤทธิ์");
assert!(thai_width("น้ำ", 10.0, |_, _| 0.0) > 0.0);
}
}
+4 -1
View File
@@ -45,7 +45,10 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
(cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK`
background and border, list-box selection.
The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined
Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold
Unicode CMap; otherwise Helvetica (WinAnsi). Thai in a text field or combo box that the font
can't hold is drawn from the craft-fonts Thai face with Add text's writer
(`pdfcraft_fonts::shaped_text`), beside the field's font, keeping `/DA`; a build without a Thai
face refuses it. Any other value or button caption the font can't hold
(Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat
keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is
marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn)
+86 -27
View File
@@ -23,6 +23,7 @@
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::shaped_text::{self, Piece, ShapedFonts};
use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with};
use crate::{Field, FieldKind, Widget, acroform, flags};
@@ -747,6 +748,21 @@ fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool {
enc.first_unencodable(text).is_none()
}
/// Why text field `f` can't take `text`: it has Thai that a widget's font can't hold and this
/// build can't draw (it has no Thai face, or the face lacks a character), which would come out
/// as `?`. `None` for text it can draw, and for a password field, which shows `*`.
pub(crate) fn thai_refusal(doc: &Document, f: &Field, text: &str) -> Option<String> {
if f.has(flags::PASSWORD) || !text.chars().any(shaped_text::is_thai) {
return None;
}
let held = |w: &Widget| {
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
dr_font(doc, &da.font).is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && text.split(['\n', '\r']).all(|l| enc.can_encode(l)))
};
if f.widgets.iter().all(held) { None } else { shaped_text::thai_refusal(text) }
}
/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box.
fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>, Option<Overflow>) {
let wobj = doc.get(w.obj);
@@ -778,36 +794,43 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
values.first().cloned().unwrap_or_default()
};
let text = if f.has(flags::PASSWORD) { "*".repeat(text.chars().count()) } else { text };
let can_show = |enc: &FontText| {
let can_show = |enc: &FontText, text: &str| {
if f.kind == FieldKind::List {
f.options.iter().all(|(_, display)| enc.can_encode(display))
} else if f.kind == FieldKind::Text && f.has(flags::MULTILINE) {
text.split(['\n', '\r']).all(|line| enc.can_encode(line))
} else {
enc.can_encode(&text)
enc.can_encode(text)
}
};
let (font_name, font_obj, enc) = match dr_font(doc, &da.font).filter(|(_, enc)| can_show(enc)) {
let dr = dr_font(doc, &da.font);
// Thai the form's font can't hold (it isn't a simple font encoding it) is drawn from the Thai
// face, as Add text draws it: Type 3 glyphs beside the form's font, which draws the rest. One
// line of a text field or combo box at a time; the `/DA` is kept.
let thai = matches!(f.kind, FieldKind::Text | FieldKind::Combo)
&& !f.is_comb()
&& text.chars().any(shaped_text::is_thai)
&& !dr.as_ref().is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && can_show(enc, &text))
&& shaped_text::thai_refusal(&text).is_none();
let latin_text: String = if thai { text.chars().filter(|c| !shaped_text::is_thai(*c)).collect() } else { text.clone() };
let (font_name, font_obj, enc) = match dr.filter(|(_, enc)| can_show(enc, &latin_text)) {
Some((o, enc)) => (da.font.clone(), o, enc),
None => ("Helv".to_string(), helvetica(), FontText::WinAnsi),
};
let width_of = |text: &str, size: f64| enc.width(text, size);
let width_of =
|text: &str, size: f64| if thai { shaped_text::thai_width(text, size, |s, size| enc.width(s, size)) } else { enc.width(text, size) };
// How far a line's ink reaches below and above its baseline (em), where Thai stacks or
// descenders reach past the form font's (Helvetica's) -0.207 and 0.718; `None` otherwise.
let thai_ink = |line: &str| thai.then(|| shaped_text::thai_reach(line)).flatten().map(|[lo, hi]| [lo.min(-0.207), hi.max(0.718)]);
let (mut c, bw) = frame(doc, &wd, width, height);
let pad = 2.0 + bw;
let inner_w = (width - 2.0 * pad).max(1.0);
let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding);
let mut body: Vec<u8> = Vec::new();
// The first character drawn that the font can't hold (drawn as `?`), for the caller to report.
let mut unencodable: Option<char> = None;
let mut show = |body: &mut Vec<u8>, x: f64, y: f64, text: &str| {
// The lines to draw (where, and what), drawn once all are known: their Thai shares fonts.
let mut shown: Vec<(f64, f64, String)> = Vec::new();
let mut show = |x: f64, y: f64, text: &str| {
// A line break in a single-line value is a space, as in viewers (multiline text is split).
let text: String = text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect();
if unencodable.is_none() {
unencodable = enc.first_unencodable(&text);
}
body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
body.extend(literal(&enc.encode(&text)));
body.extend_from_slice(b" Tj\n");
shown.push((x, y, text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect()));
};
let x_for = |text: &str, size: f64| -> f64 {
let tw = width_of(text, size);
@@ -834,7 +857,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
if values.contains(export) {
c.push_str(&format!("0.6 0.75 0.86 rg\n{} {} {} {} re f\n", n(bw), n(y - line), n(width - 2.0 * bw), n(line)));
}
show(&mut body, pad, y - line + size * 0.25, display);
show(pad, y - line + size * 0.25, display);
y -= line;
}
}
@@ -844,18 +867,20 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
if size == 0.0 {
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
}
let mut y = height - pad - size * 0.85;
let lines = wrap_with(&text, size, inner_w, width_of);
// The first line hangs from the top by its ascent: higher for stacked Thai marks.
let mut y = height - pad - size * lines.first().and_then(|l| thai_ink(l)).map_or(0.85, |[_, hi]| hi.max(0.85));
// The lines whose descent stays inside the border; the rest are cut off.
let mut room = 0;
for line in &lines {
if y < -size {
break;
}
if y - 0.207 * size >= bw - 0.01 {
let descent = thai_ink(line).map_or(0.207, |[lo, _]| -lo);
if y - descent * size >= bw - 0.01 {
room += 1;
}
show(&mut body, x_for(line, size), y, line);
show(x_for(line, size), y, line);
y -= size * 1.15;
}
if room < lines.len() {
@@ -872,12 +897,22 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of)
};
}
// Centre Helvetica's ascent (0.718) and descent (0.207) vertically.
let y = (height - 0.925 * size) / 2.0 + 0.207 * size;
// Centre Helvetica's ascent (0.718) and descent (0.207) vertically, or the reach of
// Thai stacks and descenders, which an automatic size also keeps inside the padding.
let reach = thai_ink(&text);
if let Some([lo, hi]) = reach
&& da.size == 0.0
{
size = size.min((height - 2.0 * pad) / (hi - lo)).max(4.0);
}
let (y, ink) = match reach {
Some([lo, hi]) => ((height - (hi - lo) * size) / 2.0 - lo * size, (hi - lo) * size),
None => ((height - 0.925 * size) / 2.0 + 0.207 * size, 0.925 * size),
};
// The clip box is inside the border; a line taller than it is cut at the top and bottom.
let room_h = height - 2.0 * bw;
if 0.925 * size > room_h + 0.01 {
overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size });
if ink > room_h + 0.01 {
overflow = Some(Overflow::High { height: ink, room: room_h, size });
}
if comb {
let cells = f.max_len.unwrap_or(1).max(1);
@@ -885,18 +920,19 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
c.push_str(&comb_dividers(doc, &wd, width, height, cells));
for (i, ch) in text.chars().take(cells).enumerate() {
let s = ch.to_string();
show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s);
show(cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s);
}
} else {
let tw = width_of(&text.replace(['\n', '\r'], " "), size);
if overflow.is_none() && tw > inner_w + 0.01 {
overflow = Some(Overflow::Wide { width: tw, room: inner_w, size });
}
show(&mut body, x_for(&text, size), y, &text);
show(x_for(&text, size), y, &text);
}
}
}
}
let (body, fonts, unencodable) = draw_lines(&shown, size, &font_name, font_obj, &enc, thai);
// Acrobat regenerates only what is inside /Tx BMC when it redraws a field (e.g. on focus
// changes), discarding what came before it (issues #1613, #633). The background and border
// therefore go inside the marked-content block too, so a redraw keeps them with the text.
@@ -913,8 +949,6 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
.into_bytes();
content.extend(body);
content.extend_from_slice(b"ET\nQ\nEMC\n");
let mut fonts = Dict::new();
fonts.set(font_name.into_bytes(), font_obj);
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
let mut d = Dict::new();
@@ -927,6 +961,31 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
(Stream::flate(d, &content), unencodable, overflow)
}
/// The text operators for the `shown` lines (where each starts, and what it says) at `size`, in
/// the field's font `font_name` (`font`, written with `enc`) and, with `thai`, their Thai from the
/// Thai face; the font resources they use, and the first character drawn as `?` because the
/// font can't hold it, for the caller to report.
fn draw_lines(shown: &[(f64, f64, String)], size: f64, font_name: &str, font: Object, enc: &FontText, thai: bool) -> (Vec<u8>, Dict, Option<char>) {
let mut fonts = Dict::new();
fonts.set(font_name.as_bytes().to_vec(), font);
let lines: Vec<String> = shown.iter().map(|(_, _, text)| text.clone()).collect();
let (pieces, shaped) = if thai {
shaped_text::appearance_lines(&lines, &mut fonts)
} else {
(lines.into_iter().map(|l| vec![Piece::Latin(l)]).collect(), ShapedFonts::default())
};
let mut body = Vec::new();
let mut current = None;
for ((x, y, _), pieces) in shown.iter().zip(&pieces) {
shaped.show_line(&mut body, pieces, *x, *y, size, font_name, |s| enc.encode(s), |s| enc.width(s, size), &mut current);
}
let unencodable = pieces.iter().flatten().find_map(|p| match p {
Piece::Latin(s) => enc.first_unencodable(s),
Piece::Shaped(_) => None,
});
(body, fonts, unencodable)
}
/// On/Off appearances for a check box or radio button that has none (a check mark or a dot,
/// drawn as paths, so no symbol font is needed).
/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the
+3
View File
@@ -75,6 +75,9 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
}
};
let target = copy.get_or_insert_with(|| doc.clone());
let mut stream = stream;
// Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(target, &mut stream.dict);
let ap = target.add(Object::Stream(stream));
let mut fresh = Dict::new();
match &state {
+3 -1
View File
@@ -323,8 +323,10 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>
let mut missing = None;
let ap = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true);
let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true);
missing = m;
// Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict);
let r = doc.add(Object::Stream(s));
let mut d = Dict::new();
d.set(b"N".to_vec(), Object::Ref(r));
+55
View File
@@ -967,6 +967,61 @@ fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() {
assert!(need_appearances(&doc));
}
/// Thai typed into a field whose `/DA` font (Helvetica, WinAnsi) can't hold it is drawn from the
/// Thai face as Add text draws it: Type 3 glyphs (stream objects of their own, with a ToUnicode
/// map) beside the field's font, which draws the rest, so every viewer shows the same glyphs. The
/// `/DA` is kept, nothing is marked and other viewers aren't asked to redraw. The ink of stacked
/// marks and descenders stays inside the border, an automatic size shrinking to keep it there.
/// A build without a Thai face refuses the text, saying why, and keeps the old value.
#[test]
fn thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(),
"<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 0 Tf 0 g) /V (Ada) /Rect [50 700 250 720] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (big) /DA (/Helv 16 Tf 0 g) /Rect [50 600 350 640] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /Ff 4096 /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 500] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
let thai = "ชื่อ: บุญฤทธิ์ น้ำฎู";
if pdfcraft_fonts::document_thai_font().is_none() {
let err = set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap_err();
assert!(matches!(&err, FormError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err}");
assert_eq!(field(&fields(&doc), "name").value, ["Ada"]);
assert!(!need_appearances(&doc) && undrawn_fields(&doc).is_empty());
return;
}
set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap();
set_value(&mut doc, "big", &FieldValue::Text(thai.into())).unwrap();
set_value(&mut doc, "note", &FieldValue::Text(format!("น้ำฎู {thai}\nปั้น ฎู"))).unwrap();
let doc = reopen(&doc);
assert!(undrawn_fields(&doc).is_empty() && !need_appearances(&doc));
let (lo, hi) = (pdfcraft_fonts::shaped_text::thai_reach("ฎู").unwrap()[0], pdfcraft_fonts::shaped_text::thai_reach("น้ำ").unwrap()[1]);
for (name, height) in [("name", 20.0), ("big", 40.0), ("note", 100.0)] {
let all = fields(&doc);
let w = &field(&all, name).widgets[0];
let wd = doc.get(w.obj).as_dict().unwrap().clone();
assert!(wd.get(b"DA").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().starts_with("/Helv ")), "the /DA is kept");
let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
let ap = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
assert!(!ap.contains('?') && ap.contains("/PCTh1 ") && ap.contains("(: ) Tj"), "{ap}");
let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).unwrap();
let font = fonts.get(b"PCTh1").and_then(|f| f.as_dict()).unwrap();
assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..]));
let Object::Stream(cmap) = &*doc.get(font.reference(b"ToUnicode").unwrap()) else { panic!("ToUnicode is a stream object") };
assert!(String::from_utf8_lossy(&cmap.decoded().unwrap()).contains("<0E190E490E33>"), "น้ำ copies as typed");
// The size each line is drawn at, and where: every line's ink is inside the box.
let size: f64 = ap.split(" Tf").next().and_then(|s| s.rsplit(' ').next()).and_then(|s| s.parse().ok()).unwrap();
let ys: Vec<f64> = ap.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect();
let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min));
assert!(top + hi * size <= height + 1e-3 && bottom + lo * size >= -1e-3, "{name}: {ys:?} at {size} in {height}");
}
}
/// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids
/// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off.
#[test]
+8 -1
View File
@@ -206,6 +206,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
{
return invalid(format!("{:?} takes at most {max} characters", f.name));
}
// Thai this build can't draw is refused (before any script runs), rather than stored
// and drawn as `?`.
if let Some(why) = appearance::thai_refusal(doc, f, t) {
return invalid(format!("{:?} can't take this text: {why}", f.name));
}
// Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value.
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?;
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
@@ -430,7 +435,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
// fonts can't draw the date language's (#5303).
let mut english: Option<Option<String>> = None;
for w in &f.widgets {
let (stream, missing) = match &shown {
let (mut stream, missing) = match &shown {
Some(s) => {
let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false);
// Taken when the English run happened and draws whole, even where it is the
@@ -447,6 +452,8 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
None => appearance::field_appearance_drawn(doc, f, w, values, true),
};
undrawn |= missing.is_some();
// Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict);
let existing_ap = in_place
.then(|| {
doc.get(w.obj)
+58
View File
@@ -480,6 +480,64 @@ pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRe
Ok(())
}
/// The objects `value` (of `src`) refers to, directly or through others, each once whatever the
/// cycles: what [`import_value`] copies. Refused past `max_objects` objects or `max_bytes` bytes
/// of stream data, with a message that says how much there is.
pub fn reachable(src: &Document, value: &Object, max_objects: usize, max_bytes: usize) -> Result<Vec<ObjRef>, OrganizeError> {
let mut seen = HashSet::new();
let mut found = Vec::new();
let mut bytes = 0usize;
// The values still to look into: owned, or an object of `src`.
let mut pending: Vec<std::sync::Arc<Object>> = vec![std::sync::Arc::new(value.clone())];
while let Some(next) = pending.pop() {
let mut refs = Vec::new();
let mut inside: Vec<&Object> = vec![&next];
while let Some(o) = inside.pop() {
match o {
Object::Ref(r) => refs.push(*r),
Object::Array(a) => inside.extend(a.iter()),
Object::Dict(d) => inside.extend(d.iter().map(|(_, v)| v)),
Object::Stream(s) => {
bytes = bytes.saturating_add(s.raw.len());
inside.extend(s.dict.iter().map(|(_, v)| v));
}
_ => {}
}
}
for r in refs {
if seen.insert(r) {
found.push(r);
pending.push(src.get(r));
}
}
if found.len() > max_objects || bytes > max_bytes {
return Err(OrganizeError::Invalid(format!(
"it uses more than {max_objects} objects or {} MB of fonts, images and other data; copy fewer objects at a time",
max_bytes >> 20
)));
}
}
Ok(found)
}
/// Copy `value` from `src` into `dst` with every object it refers to, each once (cycles end), as
/// Insert Pages copies a page's resources; what equals an object already in `dst` (the same font
/// or image) is shared rather than stored twice. Returns `value` as `dst` refers to it. A chain
/// nested more than 256 levels deep is refused; [`reachable`] bounds the size first.
pub fn import_value(dst: &mut Document, src: &Document, value: &Object) -> Result<Object, OrganizeError> {
let before: HashSet<u32> = dst.object_numbers().into_iter().collect();
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
let copied = copier.copy_value(dst, value, None)?;
// Held in an array object while duplicates merge (an array is never a resource that merges
// itself away), so the references in it follow them.
let holder = dst.add(Object::Array(vec![copied]));
let created = added_objects(dst, &before);
crate::dedupe::dedupe_resources(dst, &created, true);
let held = dst.get(holder).as_array().and_then(|a| a.first().cloned()).unwrap_or(Object::Null);
dst.free(holder);
Ok(held)
}
/// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations),
/// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box
/// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the
+2 -2
View File
@@ -33,8 +33,8 @@ pub use comments::import_comments;
pub use dedupe::dedupe_resources;
pub use import::{
CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with,
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, insert_file_pages,
page_as_form, split, split_ranges,
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, import_value,
insert_file_pages, page_as_form, reachable, split, split_ranges,
};
pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
pub use outline::{
+3 -1
View File
@@ -18,7 +18,7 @@ mod links;
mod prefs;
mod window;
pub(crate) use clipboard::{copy_refused, print_refused};
pub(crate) use clipboard::{ObjectClipMark, copy_refused, print_refused};
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use files::same_file;
use frame::migrate_panel_state;
@@ -245,6 +245,8 @@ impl PdfCraftApp {
progress_notice: None,
page_clipboard: None,
comment_clipboard: None,
object_clipboard: None,
object_clip_mark: Default::default(),
grid_drop: None,
last_snapshot: None,
last_copied_image: None,
+114
View File
@@ -15,6 +15,24 @@ enum ClipboardPaste {
Image(std::sync::Arc<egui::ColorImage>),
}
/// What objects copied in Edit a PDF put on the system clipboard for other applications, so ⌘V
/// can tell whether it still holds that copy or something copied since: their text, or else the
/// first picture's pixels (a hash of them), or nothing.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) enum ObjectClipMark {
Text(String),
Picture(u64),
#[default]
None,
}
fn picture_hash(width: usize, height: usize, rgba: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(width, height, rgba).hash(&mut h);
h.finish()
}
impl PdfCraftApp {
/// Whether text and images may be copied out of document `id`; says why not when they may
/// not. Reading aloud and accessibility tools don't ask (assistive technology may read
@@ -53,8 +71,14 @@ impl PdfCraftApp {
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
// Objects selected together in Edit a PDF take ⌘C and ⌘X.
let objects = (copy || cut) && self.quick_tool == QuickTool::EditText && self.views.get(i).is_some_and(|v| v.objects.count() > 0);
if objects {
self.copy_objects(i, ctx, cut);
}
let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty());
if copy
&& !objects
&& (text_selected || !self.copy_selected_comments(i))
&& !self.copy_selected_text(i, ctx)
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
@@ -64,12 +88,18 @@ impl PdfCraftApp {
self.copy_page_image(sel.page, sel.index);
}
if cut
&& !objects
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
&& let Some(id) = self.views.get(i).map(|v| v.id)
&& self.may_copy(id)
{
self.cut_page_image(sel.page, sel.index);
}
if (paste || !pastes.is_empty()) && self.quick_tool == QuickTool::EditText && self.object_clipboard.is_some() && self.pastes_objects(&pastes)
{
self.paste_objects(i);
return;
}
if !pastes.is_empty() {
self.receive_pastes(i, pastes, true);
return;
@@ -92,6 +122,90 @@ impl PdfCraftApp {
}
}
/// ⌘C / ⌘X in Edit a PDF with objects selected together: copy them for ⌘V in any open
/// document, and put their text (or else their first picture) on the system clipboard for
/// other applications. A cut then deletes them, as one undo step.
pub(crate) fn copy_objects(&mut self, i: usize, ctx: &egui::Context, cut: bool) -> bool {
let Some((id, Some((page, targets)))) = self.views.get(i).map(|v| (v.id, v.objects.selection())) else { return false };
if !self.may_copy(id) {
return false;
}
let Some(doc) = self.session.get(id) else { return false };
if cut && !doc.allows_modification() {
self.notify_error(tl!("The document's security settings don't allow this change"));
return false;
}
let clip = match doc.copy_objects(page, &targets) {
Ok(clip) => clip,
Err(e) => {
self.notify_error(e);
return false;
}
};
// A newer copy wins over a whole-document copy still reading its pages.
self.document_copy = None;
let text = clip.text();
let picture =
clip.objects().iter().find(|o| o.target.kind == pdfcraft_engine::ObjectKind::Image && o.content_type == "image").map(|o| o.target.index);
self.object_clip_mark = if !text.is_empty() {
ctx.copy_text(text.clone());
ObjectClipMark::Text(text)
} else if let Some(index) = picture.filter(|_| self.system_clipboard && cfg!(not(target_arch = "wasm32")))
&& self.copy_page_image(page, index)
&& let Some((w, h, rgba)) = &self.last_copied_image
{
ObjectClipMark::Picture(picture_hash(*w as usize, *h as usize, rgba))
} else {
ObjectClipMark::None
};
self.object_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
if cut && let Some(delete) = pdfcraft_engine::Edit::delete_objects(page, targets) {
self.apply_edit(delete);
}
true
}
/// Whether ⌘V's `pastes` (what the system clipboard gave, if anything) are the objects
/// copied in Edit a PDF, rather than something copied since, here or in another application.
fn pastes_objects(&self, pastes: &[ClipboardPaste]) -> bool {
pastes.iter().all(|paste| match (paste, &self.object_clip_mark) {
(ClipboardPaste::Text(text), ObjectClipMark::Text(copied)) => text.replace("\r\n", "\n").trim_end() == copied.trim_end(),
(ClipboardPaste::Text(text), ObjectClipMark::None) => !pdfcraft_engine::is_xfdf_comments(text),
(ClipboardPaste::Image(image), ObjectClipMark::Picture(hash)) => {
let rgba: Vec<u8> = image.pixels.iter().flat_map(egui::Color32::to_srgba_unmultiplied).collect();
picture_hash(image.size[0], image.size[1], &rgba) == *hash
}
(ClipboardPaste::Image(_), ObjectClipMark::None) => true,
_ => false,
})
}
/// ⌘V in Edit a PDF: the copied objects onto the current page of tab `i`, as page content
/// that draws the same (one undo step), and selected.
pub(crate) fn paste_objects(&mut self, i: usize) -> bool {
let Some((id, page)) = self.views.get(i).map(|v| (v.id, v.current)) else { return false };
let (Some(doc), Some(clipboard)) = (self.session.get(id), self.object_clipboard.as_mut()) else { return false };
if !doc.allows_modification() {
self.notify_error(tl!("The document's security settings don't allow this change"));
return false;
}
let before = doc.editable_objects(page).unwrap_or_default();
let edit = match clipboard.paste(doc, page) {
Ok(edit) => edit,
Err(e) => {
self.notify_error(e);
return false;
}
};
if !self.apply_edit(edit) {
return false;
}
if let (Some(Ok(after)), Some(view)) = (self.session.get(id).map(|d| d.editable_objects(page)), self.views.get_mut(i)) {
view.objects.select_pasted(page, &before, after);
}
true
}
/// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so
/// pictures and copied comments land on the page.
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, on_canvas: bool) {
+5
View File
@@ -50,8 +50,13 @@ impl PdfCraftApp {
&& (pages_owns_keyboard || !ctx.egui_wants_keyboard_input())
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
{
let view = &self.views[i];
// A paragraph or text box open for typing keeps ⌘A for its own text.
let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none();
if pages_owns_keyboard {
self.views[i].select_all_pages();
} else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) {
object_ui::select_all(&mut self.views[i], doc);
} else if self.is_preparing() {
if let Some(doc) = self.session.get(self.views[i].id) {
prepare::select_all(&mut self.views[i], &doc.form);
+3 -1
View File
@@ -917,6 +917,7 @@ fn re_resolve_image_selection(
fn content_page(edit: &Edit) -> Option<usize> {
match edit {
Edit::MoveObjects { page, .. }
| Edit::DeleteObjects { page, .. }
| Edit::EditTextLine { page, .. }
| Edit::EditTextBlock { page, .. }
| Edit::EditPageImage { page, .. }
@@ -924,7 +925,8 @@ fn content_page(edit: &Edit) -> Option<usize> {
| Edit::AddImage { page, .. }
| Edit::UpdateContent { page, .. }
| Edit::DeleteContent { page, .. }
| Edit::ReplaceImage { page, .. } => Some(*page),
| Edit::ReplaceImage { page, .. }
| Edit::PasteObjects { page, .. } => Some(*page),
_ => None,
}
}
+4
View File
@@ -603,6 +603,10 @@ pub struct PdfCraftApp {
pub page_clipboard: Option<PageClip>,
/// Copied comments (XFDF), to paste into this or another document (M14).
pub comment_clipboard: Option<CommentClip>,
/// Objects copied together in Edit a PDF, to paste into this or another document (#5989).
pub object_clipboard: Option<pdfcraft_engine::ObjectClipboard>,
/// What copying them put on the system clipboard (see [`app::ObjectClipMark`]).
pub(crate) object_clip_mark: app::ObjectClipMark,
/// Files dropped on the page grid, waiting for the pointer to say which gap they go to.
grid_drop: Option<GridDrop>,
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
+8
View File
@@ -475,6 +475,10 @@ impl PdfCraftApp {
let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s));
let index = param_index(command);
let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL);
// Objects selected together in Edit a PDF are what Copy and Cut take, and Paste brings
// back objects copied there.
let edit_objects = self.quick_tool == crate::QuickTool::EditText && !organize;
let doc = self.active_ids().and_then(|(_, id)| self.session.get(id));
let enabled = text
|| match command.id.as_str() {
NO_RECENT => false,
@@ -487,6 +491,10 @@ impl PdfCraftApp {
"edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => {
self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying())
}
"edit.copy" | CUT if edit_objects && view.is_some_and(|v| v.objects.count() > 0) => {
doc.is_some_and(|d| d.allows_copying() && (command.id != CUT || d.allows_modification()))
}
PASTE if edit_objects && self.object_clipboard.is_some() => doc.is_some_and(pdfcraft_engine::Document::allows_modification),
CUT => organize && enabled_id("page.cut"),
PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"),
SELECT_ALL | DELETE => has_doc,
+63 -46
View File
@@ -48,6 +48,39 @@ impl ObjectSelection {
self.selected.len()
}
/// The page and references of the objects selected together, if any: what Copy and Cut take.
pub(crate) fn selection(&self) -> Option<(usize, Vec<ObjectTarget>)> {
self.page.filter(|_| !self.selected.is_empty()).map(|page| (page, self.selected.iter().map(|o| o.target).collect()))
}
/// Select what a paste added to `page`, as Acrobat selects pasted objects: the objects of
/// `after` (the page's inventory with the paste) that `before` (without it) didn't have. They
/// are taken at the next `prepare`, which sees the new document generation.
pub(crate) fn select_pasted(&mut self, page: usize, before: &[EditableObject], after: Vec<EditableObject>) {
let same = |a: &EditableObject, b: &EditableObject| {
a.target.kind == b.target.kind
&& a.content_type == b.content_type
&& a.text == b.text
&& a.rect.iter().zip(b.rect).all(|(x, y)| (x - y).abs() < 0.01)
};
let mut old: Vec<&EditableObject> = before.iter().collect();
let added: Vec<_> = after
.into_iter()
.filter(|o| match old.iter().position(|b| same(b, o)) {
Some(i) => {
old.swap_remove(i);
false
}
None => true,
})
.collect();
self.clear();
if !added.is_empty() && added.len() <= MAX_MOVE_OBJECTS {
self.page = Some(page);
self.pending = Some(added);
}
}
/// Edit text's input is blocked: a dialog, the palette, another window or another tab has it.
pub(crate) fn input_blocked(&self) -> bool {
self.input_blocked
@@ -188,6 +221,25 @@ fn leave_single(view: &mut DocView) {
view.block_drag = None;
}
/// ⌘A in Edit a PDF: every object on the current page selected together, as Acrobat selects
/// them (paragraphs, pictures and added content), instead of the page's text. A page whose
/// objects can't be selected together, or has too many, says so and keeps what was selected.
pub(crate) fn select_all(view: &mut DocView, doc: &Document) {
let page = view.current;
match view.objects.inventory(doc, page) {
Ok(objects) if objects.len() > MAX_MOVE_OBJECTS => view.objects.notice = Some(tl!("Select at most 1000 objects at a time.").into()),
Ok(objects) => {
view.objects.clear();
if !objects.is_empty() {
leave_single(view);
view.objects.page = Some(page);
view.objects.selected = objects;
}
}
Err(_) => view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into()),
}
}
/// What of the objects lies under `p` on `page`: the objects selected together (a drag moves
/// them), or the frame around the paragraph open for typing (`open`: its box on screen), which
/// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the
@@ -288,7 +340,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut D
let deleted =
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace));
if deleted {
view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect());
view.pending_edit = Edit::delete_objects(page, view.objects.selected.iter().map(|o| o.target).collect());
return true;
}
}
@@ -503,30 +555,7 @@ pub(crate) fn delete_keys(ctx: &egui::Context, view: &mut DocView) {
}
// The selection stays until the edit applies: `prepare` clears it when the document changes,
// and a refused delete (text used as a clipping path) leaves it selected.
view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect());
}
/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step.
/// Each deletion renumbers only what follows it in its own inventory, so paragraphs and images
/// go from the last to the first, and added content, which the source inventories leave out,
/// after them.
fn delete_edit(page: usize, mut targets: Vec<ObjectTarget>) -> Option<Edit> {
targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index)));
targets.dedup();
let mut edits: Vec<Edit> = targets
.into_iter()
.map(|t| match t.kind {
ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: pdfcraft_engine::BlockStyle::default() },
ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: pdfcraft_engine::ImageEdit::Delete },
ObjectKind::Added => Edit::DeleteContent { page, index: t.index },
})
.collect();
if edits.len() > 1 {
// "Delete content" is an edit label already, so the history reads the same in every language.
Some(Edit::Batch { label: "Delete content".into(), edits })
} else {
edits.pop()
}
view.pending_edit = Edit::delete_objects(page, view.objects.selected.iter().map(|o| o.target).collect());
}
pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) {
@@ -569,32 +598,20 @@ mod tests {
ObjectTarget { kind, index }
}
/// Deleting renumbers what follows in the same inventory, so the batch takes each inventory
/// from its last item to its first, and added content (outside the source inventories) last.
/// Several objects are one step, each object once (the engine orders the deletions).
#[test]
fn several_objects_delete_last_first_in_one_step() {
let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)];
let Some(Edit::Batch { label, edits }) = delete_edit(0, targets) else { panic!("several objects make one batch") };
assert_eq!(label, "Delete content");
let order: Vec<(char, usize)> = edits
.iter()
.map(|e| match e {
Edit::EditTextBlock { block, text, .. } => {
assert!(text.is_empty());
('t', *block)
}
Edit::EditPageImage { index, change: pdfcraft_engine::ImageEdit::Delete, .. } => ('i', *index),
Edit::DeleteContent { index, .. } => ('a', *index),
other => panic!("unexpected {}", other.label().wording),
})
.collect();
assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]);
fn several_objects_delete_in_one_step() {
let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 1)];
let edit = Edit::delete_objects(0, targets).unwrap();
let once = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0)];
assert!(matches!(&edit, Edit::DeleteObjects { page: 0, objects } if *objects == once), "{edit:?}");
assert_eq!(edit.label().wording, "Delete content");
}
#[test]
fn one_object_deletes_with_its_own_edit_and_none_with_nothing() {
assert!(matches!(delete_edit(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. })));
assert!(delete_edit(0, Vec::new()).is_none());
assert!(matches!(Edit::delete_objects(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. })));
assert!(Edit::delete_objects(0, Vec::new()).is_none());
}
/// Selecting content objects on a page and pressing Delete must queue one edit that removes
+111
View File
@@ -504,3 +504,114 @@ fn a_double_click_on_a_grouped_paragraph_opens_it() {
h.run_steps(2);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing");
}
fn texts(h: &Harness<'static, PdfCraftApp>, view: usize) -> Vec<String> {
let app = h.state();
app.session.get(app.views[view].id).unwrap().editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect()
}
fn can_undo(h: &Harness<'static, PdfCraftApp>, view: usize) -> Option<String> {
let app = h.state();
app.session.get(app.views[view].id).unwrap().can_undo().map(str::to_string)
}
fn keys(h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers, key: Key) {
h.key_press_modifiers(modifiers, key);
h.run_steps(4);
}
/// ⌘A in Edit a PDF selects every object on the page, as Acrobat does: paragraphs, artwork and
/// added content. The arrow keys then move them all and Delete removes them all, each as one step.
#[test]
fn command_a_selects_every_object_on_the_page_to_move_and_delete_together() {
let mut h = harness();
let before = objects(&h);
assert_eq!(before.len(), 4);
keys(&mut h, Modifiers::COMMAND, Key::A);
h.get_by_label("4 objects selected");
assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "objects, not the page's text");
keys(&mut h, Modifiers::SHIFT, Key::ArrowRight);
assert_eq!(can_undo(&h, 0).as_deref(), Some("Move objects"));
for (old, new) in before.iter().zip(objects(&h)) {
assert!((new.rect[0] - old.rect[0] - 10.0).abs() < 0.01, "{old:?} -> {new:?}");
}
h.get_by_label("4 objects selected");
keys(&mut h, Modifiers::NONE, Key::Delete);
assert!(objects(&h).is_empty(), "{:?}", objects(&h));
assert_eq!(can_undo(&h, 0).as_deref(), Some("Delete content"));
keys(&mut h, Modifiers::COMMAND, Key::Z);
assert_eq!(objects(&h).len(), 4, "one undo brings every object back");
}
/// In a paragraph open for typing, ⌘A selects its text, as before.
#[test]
fn command_a_in_an_open_paragraph_selects_its_text_not_the_objects() {
let mut h = harness();
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::NONE);
assert!(h.state().views[0].line_editor.is_some());
keys(&mut h, Modifiers::COMMAND, Key::A);
assert!(h.query_all_by_label_contains("objects selected").next().is_none());
assert!(h.state().views[0].line_editor.is_some(), "the paragraph stays open");
}
/// ⌘C, ⌘X and ⌘V take the objects selected together: a paste onto their own page lands 18 pt down
/// and right, selected; a paste into another document draws them there; a cut is one step.
#[test]
fn objects_copy_cut_and_paste_with_the_keys_on_the_page_and_into_another_document() {
let mut h = harness();
h.state_mut().system_clipboard = false;
let unchanged = can_undo(&h, 0);
keys(&mut h, Modifiers::COMMAND, Key::A);
keys(&mut h, Modifiers::COMMAND, Key::C);
assert_eq!(can_undo(&h, 0), unchanged, "a copy changes nothing");
assert!(h.state().object_clipboard.is_some());
keys(&mut h, Modifiers::COMMAND, Key::V);
assert_eq!(can_undo(&h, 0).as_deref(), Some("Paste"));
let pasted = texts(&h, 0);
for text in ["Group headline", "Keep here", "Added note"] {
assert_eq!(pasted.iter().filter(|t| t.contains(text)).count(), 2, "{text}: {pasted:?}");
}
assert!(h.query_all_by_label_contains("objects selected").next().is_some(), "the pasted objects are selected");
let moved = objects(&h).into_iter().filter(|o| o.text.as_deref() == Some("Keep here")).map(|o| o.rect).collect::<Vec<_>>();
assert!((moved[1][0] - moved[0][0] - 18.0).abs() < 0.01 && (moved[0][1] - moved[1][1] - 18.0).abs() < 0.01, "{moved:?}");
// Into another open document, where it was: exactly over its own copy of the same page.
h.state_mut().open_bytes("other.pdf", None, fixture(false)).unwrap();
h.run_steps(4);
let other = h.state().active.unwrap();
assert_eq!(other, 1);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::EditText;
h.run_steps(2);
keys(&mut h, Modifiers::COMMAND, Key::V);
assert_eq!(can_undo(&h, other).as_deref(), Some("Paste"));
let there = texts(&h, other);
assert!(["Group headline", "Keep here", "Added note"].iter().all(|t| there.iter().any(|x| x.contains(t))), "{there:?}");
// Cut: copied, then deleted in one step; a paste brings it back.
keys(&mut h, Modifiers::COMMAND, Key::A);
keys(&mut h, Modifiers::COMMAND, Key::X);
assert!(texts(&h, other).is_empty(), "{:?}", texts(&h, other));
assert_eq!(can_undo(&h, other).as_deref(), Some("Delete content"));
keys(&mut h, Modifiers::COMMAND, Key::V);
assert!(texts(&h, other).iter().any(|t| t.contains("Added note")));
}
/// What another application (or another copy) put on the clipboard since wins ⌘V: the objects
/// paste only while it still holds the text their copy put there.
#[test]
fn a_newer_copy_elsewhere_wins_over_the_copied_objects() {
let mut h = harness();
h.state_mut().system_clipboard = false;
keys(&mut h, Modifiers::COMMAND, Key::A);
keys(&mut h, Modifiers::COMMAND, Key::C);
let copied = h.state().object_clipboard.as_ref().unwrap().clip().text();
assert!(copied.contains("Group headline") && copied.contains("Added note"), "{copied}");
let before = can_undo(&h, 0);
h.event(egui::Event::Paste("a word copied in another application".into()));
h.run_steps(4);
assert_eq!(can_undo(&h, 0), before, "the newer text is not the objects");
h.event(egui::Event::Paste(copied.replace('\n', "\r\n")));
h.run_steps(4);
assert_eq!(can_undo(&h, 0).as_deref(), Some("Paste"));
}
+41 -3
View File
@@ -13,6 +13,30 @@ In **Edit a PDF → Edit text & images**, select several items on the same page:
- Modifier-clicks prefer added items and text over background artwork, so labels remain selectable.
- Clear the selection or double-click an item to return to ordinary text editing and image resizing.
Finish or discard a text draft before starting a multi-selection.
- **⌘A / Ctrl+A** selects every item on the current page (paragraphs, artwork and added items),
as Acrobat's Edit PDF does, instead of the page's text. Inside a paragraph or text box open for
typing it still selects that text.
## Copy, cut and paste
With items selected together, **⌘C / Ctrl+C** (Edit ▸ Copy) copies them and **⌘X / Ctrl+X**
(Edit ▸ Cut) copies them, then deletes them as one undo step. **⌘V / Ctrl+V** (Edit ▸ Paste) pastes
them onto the current page of any open document, also after the original changed or closed, as
one undo step, and selects what it pasted. Onto the page they came from they land 18 points down
and to the right, as Acrobat offsets a paste onto the same page; elsewhere where they were; each
further paste onto a page steps another 18 points; a copy that would land off the page is centred.
A paste draws the same: it copies the items' content operators with the graphics state they were
drawn in (transformation, clip, colours, transparency, text state) and every resource they use
(fonts, images and Form artwork, ExtGStates, colour spaces, patterns, shadings), merged into the
target page's resources under names that don't clash, as Ungroup merges them. Within a document the
same font and image objects are drawn again. Into another document they are copied with what they
refer to, each once whatever the cycles, sharing objects it already holds; a paste whose resources
take more than 20,000 objects or 256 MB is refused with that reason. Pasted items are ordinary page
content: an added text box comes as page text, untagged and in no layer. The system clipboard gets
the copied text (or, without text, the first picture) for other applications; a newer copy there,
here or in another application, wins ⌘V. Text drawn as a clipping path before the selection is
refused, as is artwork without resources of its own that names one, where a paste would change it.
The supported items are the existing paragraph boxes, raster Image XObjects, whole Form artwork,
and text/images added by PdfCraft. A Form remains one item, including its nested artwork and
@@ -75,6 +99,17 @@ session. For a CLI script, include `doc_open` in that script before the move:
]
```
`{"all": true}` instead of `objects` takes every item on the page, as ⌘A does. `object_copy` takes
the same references (with `cut: true` it deletes them afterwards, one undo step) and `object_paste
{doc, page}` pastes them, into the same or another open document, as the app does:
```json
[
{"tool":"object_copy","args":{"doc":1,"page":1,"all":true}},
{"tool":"object_paste","args":{"doc":2,"page":3}}
]
```
Use the actual generation and references from your document, rather than assuming these example
values. `offset` is in displayed points: positive x moves right, positive y moves down. The optional
generation guard rejects a changed document before interpreting references. Re-list after any edit,
@@ -82,6 +117,9 @@ undo, redo, or save; there is no claim that an index persists across generations
`pdfcraft-cli run --script steps.json --root DIR` or call these tools through the opt-in MCP server.
Regression coverage: `crates/edit/src/tests.rs` (glyph bytes, neighbours, split streams, shared Forms,
rotated/cropped/UserUnit pages and refusal), `crates/engine/src/tests.rs` (permissions, history,
rendering and save/reopen), `crates/automation/tests/it/automation.rs` (generation guards and hostile
references), and `crates/ui-egui/tests/it/object_selection.rs` (real-shell selection and gestures).
rotated/cropped/UserUnit pages and refusal), `crates/edit/src/objects/clip_tests.rs` (copies that
render the same pixels, renamed resources, hostile content), `crates/engine/src/tests.rs` and
`crates/engine/src/object_clip_tests.rs` (permissions, history, rendering, save/reopen, pastes into
another document, cycles and the size limit), `crates/automation/tests/it/automation.rs` (generation
guards, hostile references, copy/cut/paste tools), and `crates/ui-egui/tests/it/object_selection.rs`
(real-shell selection, gestures, ⌘A and the clipboard keys).
+46
View File
@@ -0,0 +1,46 @@
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 2020-2024 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
SPDX-License-Identifier: Unicode-3.0
—
Portions of ICU4X may have been adapted from ICU4C and/or ICU4J.
ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
+18 -18
View File
@@ -1561,9 +1561,9 @@ tier = "P1"
milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"]
notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole"
tools = ["object_copy", "object_move"]
evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages", "crates/ui-egui/tests/it/object_selection.rs::command_a_selects_every_object_on_the_page_to_move_and_delete_together", "crates/ui-egui/tests/it/object_selection.rs::command_a_in_an_open_paragraph_selects_its_text_not_the_objects", "crates/automation/tests/it/automation.rs::objects_copy_cut_and_paste_through_tools_within_and_across_documents"]
notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole. In Edit text & images (Acrobat's Edit PDF) ⌘A selects every object on the current page together instead (paragraphs, artwork, added content), to move with the arrows or a drag, delete or copy as one step; inside a paragraph open for typing it selects that text. Headless: `all: true` in object_move and object_copy. Not observed against Acrobat: whether its ⌘A there also reaches paths and inline images (not selectable here)."
[[feature]]
id = "view.copy-with-layout"
@@ -2047,8 +2047,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.text"]
tools = ["page_add_text", "content_update"]
evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders", "crates/edit/src/tests.rs::gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured", "crates/automation/tests/it/automation.rs::added_text_takes_a_cmyk_or_gray_ink_through_tools", "crates/ui-egui/src/editing/tests.rs::recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step"]
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. The colour is RGB, or for print files gray or CMYK (#6440: page_add_text / content_update gray or cmyk, painted with g / k so black can be 100 K, kept through save and reopen; choosing another colour later, also from the Format panel, drops the ink for good and paints in RGB, while choosing the colour the ink already shows (black on 100 K) keeps it; content_update color always paints in RGB). Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/edit/src/tests.rs::added_thai_text_wraps_between_dictionary_words", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders", "crates/edit/src/tests.rs::gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured", "crates/automation/tests/it/automation.rs::added_text_takes_a_cmyk_or_gray_ink_through_tools", "crates/ui-egui/src/editing/tests.rs::recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step"]
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. The colour is RGB, or for print files gray or CMYK (#6440: page_add_text / content_update gray or cmyk, painted with g / k so black can be 100 K, kept through save and reopen; choosing another colour later, also from the Format panel, drops the ink for good and paints in RGB, while choosing the colour the ink already shows (black on 100 K) keeps it; content_update color always paints in RGB). Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style; rows end between dictionary words (ICU4X), and a word wider than the row breaks between clusters; forms/Fill and Sign are not covered. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
[[feature]]
id = "edit.find-replace"
@@ -2117,9 +2117,9 @@ area = "C"
tier = "P1"
milestone = "M7"
status = "partial"
tools = ["content_copy"]
evidence = ["crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"]
notes = "Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)."
tools = ["content_copy", "object_copy", "object_paste"]
evidence = ["crates/ui-egui/tests/it/object_selection.rs::objects_copy_cut_and_paste_with_the_keys_on_the_page_and_into_another_document", "crates/ui-egui/tests/it/object_selection.rs::a_newer_copy_elsewhere_wins_over_the_copied_objects", "crates/automation/tests/it/automation.rs::objects_copy_cut_and_paste_through_tools_within_and_across_documents", "crates/engine/src/object_clip_tests.rs::objects_copied_from_one_document_paste_into_another_and_draw_the_same", "crates/engine/src/object_clip_tests.rs::pasting_into_another_document_follows_cycles_and_refuses_past_its_limit", "crates/edit/src/objects/clip_tests.rs::pasted_objects_draw_the_same_on_another_page_under_renamed_resources", "crates/edit/src/objects/clip_tests.rs::a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same", "crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"]
notes = "Existing page objects (#5989): in Edit text & images, ⌘C / ⌘X / ⌘V and Edit ▸ Copy, Cut and Paste act on the objects selected together (⌘A, a marquee, modifier-clicks, a paragraph's frame): paragraphs, Image and Form artwork and added content. Edit::PasteObjects pastes them onto the current page of any open document as one undo step, selected: onto their own page 18 pt down and right, elsewhere where they were, each further paste a step more, centred when off the page. The paste draws the same pixels (raster-compared in tests): their content operators with the graphics state they had, and every resource they use (fonts, XObjects, ExtGStates, colour spaces, patterns, shadings) merged into the page's under names that don't clash, as Ungroup merges them; into another document the objects they refer to are copied once each, through cycles, sharing what is there, refused past 20,000 objects or 256 MB. Cut deletes as one step. The system clipboard gets their text (or the first picture) for other applications, and a newer copy wins ⌘V. Headless: object_copy (all, cut) and object_paste. Limits: pasted content is untagged and in no layer, an added text box pastes as page text, text clipping what follows and artwork that inherits resources it names are refused, no right-click Cut/Copy/Paste for these objects yet, and Acrobat's own clipboard format was not observed. Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)."
[[feature]]
id = "edit.image-replace"
@@ -2454,8 +2454,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.header_footer"]
tools = ["doc_header_footer"]
evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"]
notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded; Update previews it on the page without the headers and footers it replaces"
evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces", "crates/edit/src/tests.rs::thai_headers_footers_and_watermarks_are_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"]
notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet), with Thai drawn beside them from the craft-fonts Thai face as Add text draws it (shaped, Type 3 glyphs with a ToUnicode map, one set of fonts shared by every page; refused, saying what to do, in a build without a Thai face; #2354); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded; Update previews it on the page without the headers and footers it replaces"
[[feature]]
id = "edit.header-footer-update-remove"
@@ -2514,8 +2514,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.watermark"]
tools = ["doc_watermark"]
evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces"]
notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there); Update previews on the page without the watermarks it replaces"
evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks", "crates/ui-egui/tests/it/marks.rs::update_previews_the_page_without_the_marks_it_replaces", "crates/engine/src/tests.rs::update_previews_on_the_page_without_the_marks_it_replaces", "crates/edit/src/tests.rs::thai_headers_footers_and_watermarks_are_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"]
notes = "Helvetica, with Thai drawn from the craft-fonts Thai face as Add text draws it (refused in a build without one; #2354); fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there); Update previews on the page without the watermarks it replaces"
[[feature]]
id = "edit.watermark-add-file"
@@ -3782,8 +3782,8 @@ milestone = "M5"
status = "partial"
commands = ["comment.freetext"]
tools = ["comment_add"]
evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"]
notes = "Helvetica text with colour, size and alignment; /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet"
evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"]
notes = "Helvetica text with colour, size and alignment; Thai is shaped and drawn from the craft-fonts Thai face as Add text draws it (Type 3 glyphs with a ToUnicode map, in the appearance's resources), the box growing to hold the ink of its stacked marks and descenders, and refused, saying what to do, in a build without a Thai face (#2354); /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet"
[[feature]]
id = "comment.callout"
@@ -4626,8 +4626,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.fields"]
tools = ["form_fill", "form_fields"]
evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells"]
notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)."
evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells", "crates/forms/src/tests.rs::thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"]
notes = "in-place editor; Thai the field's /DA font can't hold is drawn from the craft-fonts Thai face as Add text draws it (Type 3 glyphs with a ToUnicode map beside the field's font, /DA kept, no /NeedAppearances asked for; the ink of stacked marks and descenders kept inside the border, an automatic size shrinking for it), and refused, saying what to do, in a build without a Thai face (#2354); comb and list fields still draw Thai as `?`, marked; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)."
[[feature]]
id = "form.fill-checkbox-radio"
@@ -5483,8 +5483,8 @@ milestone = "M5"
status = "shipped"
commands = ["sign.fill.text"]
tools = ["fill_sign_add"]
evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::escape_leaves_the_fill_and_sign_tools"]
notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Escape closes the open box, and a second Escape leaves the Fill & Sign tool for Select, as it leaves a comment tool. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)"
evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::escape_leaves_the_fill_and_sign_tools", "crates/annot/src/tests.rs::thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face", "crates/automation/tests/it/automation.rs::thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found"]
notes = "Thai is shaped and drawn from the craft-fonts Thai face as Add text draws it, the box sized to its ink, and refused in a build without a Thai face (#2354). Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Escape closes the open box, and a second Escape leaves the Fill & Sign tool for Select, as it leaves a comment tool. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)"
[[feature]]
id = "form.fill-sign-marks"
+1
View File
@@ -29,6 +29,7 @@ pub const ALLOWED_LICENCES: &[&str] = &[
"OFL-1.1",
"Ubuntu-font-1.0",
"Bitstream-Vera",
"Unicode-3.0",
"CC0-1.0",
"CC-BY-4.0",
"CC-BY-SA-4.0",