Merge remote-tracking branch 'upstream/main' into float-6366

# Conflicts:
#	crates/annot/src/draw.rs
#	crates/annot/src/tests.rs
This commit is contained in:
mtlcl410 committed 2026-11-09 03:13:30 +08:00
commit 356e8f0ba0
26 files changed
+1155 -308

No files matched your search

+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
@@ -7,12 +7,39 @@
//! endings), so callers never replace an appearance with a worse one.
//!
//! 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};
@@ -415,13 +442,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")?),
@@ -434,6 +463,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"));
@@ -441,7 +475,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
@@ -1320,27 +1354,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
@@ -98,11 +98,13 @@ fn build_appearance(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> Option<
}
/// Store a built appearance on the annotation: the cheap half of [`set_appearance`].
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> {
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, mut stream: Stream) -> Result<(), AnnotError> {
// Characters the standard fonts can't draw came out as `?`: mark the annotation with the first.
let missing = appearance::undrawable_text(d).map(|(_, c)| c);
// Points (/QuadPoints, /L, /Vertices, /InkList) past /Rect: its appearance covers them, so /Rect does too.
let markup_rect = appearance::widened_rect(d, &stream);
// 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
@@ -3148,6 +3148,69 @@ fn nums_of(d: &Dict, key: &[u8]) -> Vec<f64> {
d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default()
}
/// 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> {
+5 -5
View File
@@ -493,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.")
@@ -864,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!({
@@ -1135,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(
@@ -1154,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!({
@@ -1489,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(
+44
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");
+3 -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};
@@ -33,6 +29,8 @@ 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()
+4 -27
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> {
@@ -171,9 +170,7 @@ fn thai_words(atoms: Vec<(String, f64)>) -> Vec<Vec<(String, f64)>> {
words
}
pub(super) fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
}
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.
@@ -253,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> {
@@ -313,14 +303,7 @@ 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))
}
@@ -351,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).
+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)?;
}
+1 -1
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 {
+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),
}
}
}
+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
}
+49
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]
+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)
+10 -10
View File
@@ -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"