Integrate remaining six PdfCraft parity PRs with conflict resolutions

Includes #4325 #1952 #4180 #2529 #4320 #4805, preserving their original histories.
Keep paragraph alignment and hard/soft wrap metadata together; retain CCITT/downsampling/RGB ownership guards with bounded JBIG2 export and exact-copy symbol encoding.
Combined interaction regressions added. Validation pending.
This commit is contained in:
FredApps committed 2026-10-30 11:02:07 +01:00
129 files changed
+7949 -415

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ plan/
/assets/models
# A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here).
/craft-fonts
.claude/
Generated
+3
View File
@@ -2732,6 +2732,7 @@ version = "0.3.0"
dependencies = [
"fearless_simd",
"hayro-ccitt",
"image",
]
[[package]]
@@ -4335,6 +4336,7 @@ version = "0.15.0"
dependencies = [
"encoding_rs",
"hayro-ccitt",
"hayro-jbig2",
"hayro-syntax",
"image",
"pdfcraft-cos",
@@ -4432,6 +4434,7 @@ dependencies = [
"fax",
"flate2",
"hayro-ccitt",
"hayro-jbig2",
"proptest",
"thiserror 2.0.21",
"weezl 0.2.1",
+2 -2
View File
@@ -68,9 +68,9 @@ flate2 = { version = "1.1", default-features = false, features = ["rust_backend"
weezl = "0.2"
# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
fax = "0.2.7"
# CCITT fax decoding (pdfcraft-filters' decode_ccitt), also the independent decoder in its
# encoder's round-trip tests; already in the tree through hayro.
# Independent CCITT and JBIG2 codecs, already in the tree through hayro.
hayro-ccitt = "0.3"
hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
+11
View File
@@ -4,6 +4,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
@@ -23,6 +24,7 @@
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -202,16 +204,22 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too.
- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`.
- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images.
- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
@@ -762,3 +770,6 @@ Newest first: the date, what moved, and the new overall estimate.
## Pages sidebar keyboard refresh (2026-10-30)
PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged.
## Button export-value refresh (2026-10-30)
PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged.
+12
View File
@@ -220,6 +220,18 @@ unapplied with a warning.
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.
## Editing button export values
Use `form_set_props` with `field`, `export_value` and optional `widget`
(1-based, default 1) to change a checkbox or radio button export value.
`form_fields` returns `export_values` in widget order. The selected/default
appearance states are preserved; the operation is undoable and survives saving.
Widgets sharing an appearance state keep one export value. Bulk edits are not
supported for this widget-specific property. Empty values, `Off`, conflicting
choices and values over 4096 UTF-8 bytes return an error.
Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
## Viewer navigation history
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
+42 -9
View File
@@ -53,13 +53,13 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
t.color = parse_color(c)?;
}
if let Some(al) = a.opt_str("align")? {
t.align = match al {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
"justify" => TextAlign::Justify,
other => return Err(bad(format!("unknown align {other:?}"))),
};
t.align = parse_align(al)?;
// The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one.
t.aligns.clear();
}
if let Some(aligns) = opt_aligns(a)? {
let count = pdfcraft_engine::paragraph_align::count(&t.text);
t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align);
}
if let Some(c) = a.opt_num("char_spacing")? {
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
@@ -70,6 +70,37 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
Ok(())
}
pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
Ok(match al {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
"justify" => TextAlign::Justify,
other => return Err(bad(format!("unknown align {other:?}"))),
})
}
/// Most entries `aligns` takes: one per paragraph, and no text has this many.
const MAX_ALIGNS: usize = 4096;
/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`.
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
let Some(v) = a.get("aligns") else { return Ok(None) };
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
if items.len() > MAX_ALIGNS {
return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
}
items
.iter()
.map(|v| match v {
Value::Null => Ok(None),
Value::String(s) => parse_align(s).map(Some),
_ => Err(bad("each entry of aligns is left, center, right, justify or null")),
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
impl Automation {
pub(crate) fn content_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
@@ -160,6 +191,8 @@ impl Automation {
match &mut content {
AddedContent::Text(t) => {
if let Some(s) = a.opt_str("text")? {
// Each paragraph keeps its own alignment through the retyping.
t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns);
t.text = s.to_string();
}
style(a, t)?;
@@ -171,8 +204,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, align and char_spacing apply to text"));
if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
+15 -10
View File
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels};
use super::paths::{read_input, write_atomic};
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, content, json, tools};
/// One piece of a tool's result.
#[derive(Clone, Debug, PartialEq)]
@@ -538,7 +538,15 @@ impl Automation {
};
let index = self.paragraph_index(id, page, k, &blocks)?;
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
let width = a.opt_num("width")?;
// Explicit row alignments keep the editor's row boundaries; a width-only
// reflow rejoins soft wraps while retaining typed newlines and spaces.
let text = match a.opt_str("text")? {
Some(text) => text.to_owned(),
None if a.get("aligns").is_some() => block.editor_text,
None if width.is_some() => block.reflow_text.clone(),
None => block.text,
};
let mut style = pdfcraft_engine::BlockStyle {
ranges: parse_style_ranges(a.get("ranges"))?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
@@ -548,7 +556,7 @@ impl Automation {
line_spacing: a.opt_num("line_spacing")?,
char_spacing: a.opt_num("char_spacing")?,
scale: a.opt_num("scale")?,
width: a.opt_num("width")?,
width,
..Default::default()
};
let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?);
@@ -568,13 +576,10 @@ impl Automation {
style.color = Some(comments::parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
style.align = Some(match al {
"left" => pdfcraft_engine::TextAlign::Left,
"center" => pdfcraft_engine::TextAlign::Center,
"right" => pdfcraft_engine::TextAlign::Right,
"justify" => pdfcraft_engine::TextAlign::Justify,
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
});
style.align = Some(content::parse_align(al)?);
}
if let Some(aligns) = content::opt_aligns(&a)? {
style.aligns = aligns;
}
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
+6 -3
View File
@@ -319,9 +319,9 @@ impl Automation {
settings.color = images("color", settings.color)?;
settings.gray = images("gray", settings.gray)?;
if let Some(mono) = a.get("mono") {
let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, retain)"));
let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, jbig2, retain)"));
let mono = mono.as_object().ok_or_else(|| {
ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|retain }".into())
ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain }".into())
})?;
// A misspelled key would otherwise leave the preset's setting in place unnoticed.
if let Some(key) = mono.keys().find(|k| !MONO_KEYS.contains(&k.as_str())) {
@@ -344,6 +344,7 @@ impl Automation {
if let Some(c) = mono.get("compression") {
m.compression = match c.as_str() {
Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4,
Some("jbig2") => MonoCompression::Jbig2,
Some("retain") => MonoCompression::Retain,
_ => return Err(unknown(c)),
};
@@ -388,7 +389,9 @@ impl Automation {
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
let root = doc.root()?;
doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
// Through a reference too, as the optimizer reads it.
let version = doc.get(root).as_dict()?.get(b"Version")?.clone();
doc.resolve(&version).as_name().and_then(valid_version).map(str::to_owned)
})
.flatten();
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
+93 -25
View File
@@ -1,7 +1,7 @@
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
//! prepare a form (add fields one at a time or as a table, change and delete them).
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable};
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
use serde_json::{Value, json};
@@ -94,13 +94,20 @@ fn look_arg(v: &Value) -> Result<pdfcraft_engine::FieldLookPatch> {
})
}
/// `{"min": 0, "max": 100}` or `"none"`.
/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it.
fn validate_arg(v: &Value) -> Result<Validate> {
if v.as_str() == Some("none") {
return Ok(Validate::None);
}
let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?;
let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64));
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) {
return Err(bad(format!("unknown key {k:?} in validate (min, max)")));
}
let bound = |k: &str| match o.get(k) {
None | Some(Value::Null) => Ok(None),
Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))),
};
let (min, max) = (bound("min")?, bound("max")?);
if min.is_none() && max.is_none() {
return Err(bad("validate needs min, max or both"));
}
@@ -113,25 +120,67 @@ fn calculate_arg(v: &Value) -> Result<Calculate> {
return Ok(Calculate::None);
}
let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?;
if let Some(n) = o.get("notation").and_then(Value::as_str) {
// Before anything else is read, so a script given beside a notation isn't dropped unnoticed.
if o.contains_key("script") {
return Err(bad("a custom calculate script is set with form_set_script (event: calculate)"));
}
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) {
return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)")));
}
// A null is no value, as for the tool's own arguments.
let get = |k: &str| o.get(k).filter(|v| !v.is_null());
if let Some(n) = get("notation") {
if get("op").is_some() || get("fields").is_some() {
return Err(bad("calculate takes a notation, or op and fields, not both"));
}
let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?;
// Refused rather than stored as a different formula.
if !notation_storable(n) {
return Err(bad(NOTATION_NOT_STORABLE));
}
return Ok(Calculate::Notation(n.to_string()));
}
let op = match o.get("op").and_then(Value::as_str).unwrap_or("") {
"sum" => CalcOp::Sum,
"product" => CalcOp::Product,
"average" => CalcOp::Average,
"min" | "minimum" => CalcOp::Minimum,
"max" | "maximum" => CalcOp::Maximum,
x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))),
let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?;
let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? {
"minimum" => CalcOp::Minimum,
"maximum" => CalcOp::Maximum,
x => {
CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))?
}
};
let fields: Vec<String> =
o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default();
let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?;
let fields: Vec<String> = fields
.iter()
.map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)")))
.collect::<Result<_>>()?;
if fields.is_empty() {
return Err(bad("calculate needs fields"));
}
Ok(Calculate::Simple { op, fields })
}
/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it.
fn op_name(op: CalcOp) -> &'static str {
match op {
CalcOp::Sum => "sum",
CalcOp::Product => "product",
CalcOp::Average => "average",
CalcOp::Minimum => "min",
CalcOp::Maximum => "max",
}
}
/// A field's calculation as `form_fields` and `form_calculation_order` show it, in
/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one.
fn calculate_json(f: &FormField) -> Option<Value> {
match (&f.actions.calculate, &f.actions.scripts.calculate) {
(Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
(Calculate::Notation(n), _) => Some(json!({ "notation": n })),
(Calculate::None, Some(js)) => Some(json!({ "script": js })),
(Calculate::None, None) => None,
}
}
fn format_json(f: &Format) -> Value {
match f {
Format::None => Value::Null,
@@ -220,18 +269,15 @@ impl Automation {
if let Validate::Range { min, max } = &f.actions.validate {
o.insert("validate".into(), json!({ "min": min, "max": max }));
}
match &f.actions.calculate {
Calculate::Simple { op, fields } => {
o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields }));
}
Calculate::Notation(n) => {
o.insert("calculate".into(), json!({ "notation": n }));
}
Calculate::None => {}
if let Some(c) = calculate_json(f) {
o.insert("calculate".into(), c);
}
if !f.actions.unsupported.is_empty() {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
match f.kind {
FormFieldKind::PushButton => {
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
@@ -418,8 +464,8 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
if a.get("widget").is_some() && a.get("caption").is_none() {
return Err(bad("widget requires caption"));
if a.get("widget").is_some() && a.get("caption").is_none() && a.get("export_value").is_none() {
return Err(bad("widget requires caption or export_value"));
}
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
@@ -438,7 +484,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
for key in ["name", "rect", "rotation", "caption", "widget"] {
for key in ["name", "rect", "rotation", "caption", "export_value", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -502,6 +548,15 @@ impl Automation {
};
Ok(FieldProps {
rect,
export_value: a
.opt_str("export_value")?
.map(|value| -> Result<_> {
let index = a.opt_int("widget")?.unwrap_or(1);
let index =
usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
Ok((index, value.to_owned()))
})
.transpose()?,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
caption: a
@@ -653,15 +708,28 @@ impl Automation {
};
let order = self.doc(a)?.calculation_order();
let issue = |i: &CalculationIssue| match i {
CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }),
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
CalculationIssue::BuiltNames => json!({ "issue": "built_names" }),
};
// By field object, not name: two calculated fields can share a name in a malformed form.
let doc = self.doc(a)?;
let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect();
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
out["fields"] = order
.fields
.iter()
.map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::<Vec<_>>() }))
.map(|f| {
json!({
"name": f.name,
"formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)),
"reads": f.reads,
"listed": f.listed,
"issues": f.issues.iter().map(issue).collect::<Vec<_>>(),
})
})
.collect();
out["extra"] = json!(order.extra);
out["fixable"] = json!(order.fixable());
+10 -6
View File
@@ -391,7 +391,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@@ -475,8 +475,9 @@ pub fn tools() -> Vec<ToolDef> {
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
"export_value": { "type": "string", "description": "Checkbox/radio export value; preserves appearance states and selection. Requires a single field." },
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption or export value changes (1-based, default 1). Requires caption or export_value. See form_fields.captions and export_values." },
"read_only": { "type": "boolean" },
"required": { "type": "boolean" },
"multiline": { "type": "boolean" },
@@ -492,7 +493,7 @@ pub fn tools() -> Vec<ToolDef> {
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." },
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
}),
&["doc"],
@@ -502,7 +503,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
&["doc"],
)),
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with its `formula` (as form_fields shows calculate), the fields it reads (`reads`; a script's names built at run time aren't known) and its `issues`: missing_field (it reads `field`, which the form doesn't have, so it gets no value there), runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it) and built_names (its script also reads fields by names it builds at run time, which aren't checked). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
.cmd("form.calculation_order")
.with(schema(
json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }),
@@ -636,6 +637,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
}),
&["doc", "page", "text"],
@@ -658,6 +660,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
@@ -1278,7 +1281,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("ocr.recognize")
.with(schema(json!({}), &[])),
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, separations) are listed under skipped. Includes unsaved edits.")
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.")
.cmd("export.all_images")
.with(schema(
json!({
@@ -1425,6 +1428,7 @@ pub fn tools() -> Vec<ToolDef> {
"size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#rrggbb" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"underline": { "type": "boolean" },
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
@@ -1438,7 +1442,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
+469 -21
View File
@@ -5119,6 +5119,151 @@ fn the_calculation_order_is_read_fixed_and_set() {
assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated");
}
/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`.
fn two_calculations_with_one_name() -> Vec<u8> {
let widget = |name: &str, y: u32, reads: &str| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>",
y + 20
)
};
pdf_of(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>",
&widget("dup", 700, "x"),
&widget("dup", 660, "src"),
&widget("src", 620, "x"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>",
])
}
/// Each row of the calculation order gets its own field's formula, even where two fields share a
/// name (a malformed form): the first dup sums src, the second x.
#[test]
fn the_calculation_order_gives_each_field_its_own_formula() {
let dir = workdir("calcdup");
std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"]));
let formulas: Vec<Value> = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
assert_eq!(
formulas,
[json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })]
);
}
/// The calculation order through the tool shows each formula, in form_set_props's shape or as a
/// script, and flags a read of a field the form doesn't have and a script that builds the names
/// it reads; form_fields shows a custom script too.
#[test]
fn the_calculation_order_shows_formulas_and_what_they_cannot_read() {
let dir = workdir("calcformulas");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (i, name) in ["qty", "price", "sub", "tax", "total"].into_iter().enumerate() {
let y = 20 + 30 * i;
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
}
let script = "event.value = getField(\"sub\").value / 10 + getField(\"row\" + 1).value;";
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * pric" } }));
ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "tax", "event": "calculate", "script": script }));
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } }));
let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
assert_eq!(r["calculation_order"], json!(["sub", "tax", "total"]));
let formulas: Vec<Value> = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
assert_eq!(formulas, [json!({ "notation": "qty * pric" }), json!({ "script": script }), json!({ "op": "sum", "fields": ["sub", "tax"] })]);
assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "missing_field", "field": "pric" }]));
assert_eq!(r["fields"][1]["reads"], json!(["sub"]));
assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "built_names" }]));
assert_eq!(r["fields"][2]["issues"], json!([]));
assert_eq!(r["fixable"], false, "neither is an order to fix");
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap();
assert_eq!(tax["calculate"], json!({ "script": script }));
// A script is set with form_set_script, and form_set_props says so, also when a notation
// comes with it.
for calculate in [json!({ "script": script }), json!({ "notation": "qty * 2", "script": script })] {
let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": calculate }));
assert!(e.to_string().contains("form_set_script"), "{calculate}: {e}");
}
}
/// Every operation comes back from form_fields and form_calculation_order as form_set_props takes
/// it, so a formula can be read and given back: product and average came back as prd and avg,
/// which it refused.
#[test]
fn every_calculation_op_can_be_read_and_given_back() {
let dir = workdir("calcops");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
}
for op in ["sum", "product", "average", "min", "max"] {
let calculate = json!({ "op": op, "fields": ["a", "b"] });
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
let shown = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap()["calculate"].clone();
let order = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
assert_eq!((&shown, &order["fields"][0]["formula"]), (&calculate, &calculate), "{op}");
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": shown }));
}
}
/// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't
/// know, a value of the wrong type, a notation beside op and fields) as a bad argument, before
/// the edit runs, and changes nothing. A validate read back from form_fields, with null for the
/// bound it doesn't have, is taken back.
#[test]
fn calculate_and_validate_refuse_what_they_would_drop() {
let dir = workdir("calcstrict");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
}
let set = json!({ "op": "sum", "fields": ["a", "b"] });
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": set, "validate": { "min": 0, "max": 10 } }));
let total = |a: &mut Automation| {
let fields = ok(a, "form_fields", json!({ "doc": doc }));
fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap().clone()
};
for (calculate, says) in [
(json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"),
(json!({ "notation": "a", "op": "sum" }), "not both"),
(json!({ "notation": "a", "fields": ["b"] }), "not both"),
// A notation that wouldn't read back as itself from the script's comment.
(json!({ "notation": "a EVCALC b" }), "can't be stored"),
(json!({ "notation": "a */ x /*" }), "can't be stored"),
(json!({ "notation": " " }), "can't be stored"),
(json!({ "op": "sum", "fields": ["a", 5] }), "field names"),
(json!({ "op": "sum", "fields": "a" }), "list of field names"),
(json!({ "notation": 5 }), "notation must be a string"),
(json!({ "op": 5, "fields": ["a"] }), "op must be"),
(json!({ "fields": ["a"] }), "needs op"),
(json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""),
] {
// InvalidArgs, not Failed: an unstorable notation is refused by the tool itself, not
// only by the edit underneath it.
let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{calculate}: {e:?}");
}
for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] {
let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate }));
assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{validate}: {e:?}");
}
let t = total(&mut a);
assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed");
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": { "min": null, "max": 5 } }));
let shown = total(&mut a)["validate"].clone();
assert_eq!(shown, json!({ "min": null, "max": 5.0 }));
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": shown }));
}
#[test]
fn page_tab_orders_are_read_and_set_per_page_through_tools() {
let dir = workdir("pagetabs");
@@ -6354,6 +6499,15 @@ fn optimizing_through_tools() {
let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap();
let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false }));
assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}");
// The same with the catalog's /Version given by reference: read through it.
let v16 = cos14.add(pdfcraft_cos::Object::name("1.6"));
cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::Ref(v16))).unwrap();
let ref16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
std::fs::write(dir.join("ref16.pdf"), &ref16).unwrap();
let ref_doc = ok(&mut a, "doc_open", json!({ "path": "ref16.pdf" }))["doc"].as_u64().unwrap();
let by_ref =
ok(&mut a, "doc_optimize", json!({ "doc": ref_doc, "path": "retained-ref.pdf", "compatibility": "retain", "object_streams": false }));
assert_eq!(by_ref["pdf_version"].as_str(), Some("1.6"), "{by_ref}");
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(
@@ -6546,6 +6700,39 @@ fn a_one_bit_scan_stays_one_bit_and_exports() {
assert_eq!(white, expected);
}
/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2,
/// smaller than as Group 4, and exports as the same picture.
#[test]
fn doc_optimize_stores_black_and_white_images_as_jbig2() {
let dir = workdir("optimize-jbig2");
std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
let g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" }));
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } }));
assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}");
assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}");
let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap();
let filters: Vec<Vec<u8>> = written
.object_numbers()
.into_iter()
.filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) {
pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec),
_ => None,
})
.collect();
assert_eq!(filters, [b"JBIG2Decode".to_vec()]);
// Export ▸ Images gives the copy's picture back as it gives the scan's.
let copy = ok(&mut a, "doc_open", json!({ "path": "jbig2.pdf" }))["doc"].as_u64().unwrap();
let mut exported = |doc: u64, folder: &str| {
let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": folder }));
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 1, "{r}");
std::fs::read(files[0]["path"].as_str().unwrap()).unwrap()
};
assert_eq!(exported(copy, "from-jbig2"), exported(doc, "from-scan"));
}
/// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with
/// `mono: { compression: retain }`.
#[test]
@@ -6572,7 +6759,7 @@ fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() {
assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]);
// An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an
// object is refused, not read as the default.
for mono in [json!({ "compression": "jbig2" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] {
for mono in [json!({ "compression": "jpeg" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] {
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}");
}
}
@@ -9228,8 +9415,8 @@ fn text_box_and_callout_fill_and_border_through_tools() {
}
/// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading
/// of #2516. A third Alpha row brings its last baseline within 2.5 × size of Bravo's, so the next
/// read groups the two as one paragraph.
/// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves
/// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph.
fn three_paragraph_pdf() -> Vec<u8> {
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
use std::sync::Arc;
@@ -9278,6 +9465,10 @@ fn an_emptied_paragraph_does_not_pass_its_number_to_an_overlapping_neighbour() {
/// #2516: a paragraph number names the paragraph `text_paragraphs` listed. When an edit regroups
/// the page so that paragraph no longer exists on its own, the next edit by that number is
/// refused. It used to rewrite whichever paragraph had taken the number, and that text was gone.
///
/// Moving Alpha down (no text change) is the regrouping edit: #2517 wraps a rewrite that adds or
/// keeps rows in a marked region precisely so it can't be mistaken for a neighbour by geometry
/// alone, so a text rewrite can no longer manufacture this scenario; a pure move still can.
#[test]
fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
let dir = workdir("text-edit-stale-number");
@@ -9289,12 +9480,9 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
assert!(listed["paragraphs"][1]["text"].as_str().unwrap().starts_with("Bravo"));
// Listing again here would renumber the page, so the first edit's result says what moved.
let first = ok(
&mut a,
"text_edit",
json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
);
assert_eq!(first["stale_paragraphs"], json!([1, 2]), "the third row regroups Alpha with Bravo: {first}");
// Moving Alpha down by 21.2 pt brings its last row within 14.4 pt of Bravo, its own leading.
let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
assert_eq!(first["stale_paragraphs"], json!([1, 2]), "moving Alpha down regroups it with Bravo: {first}");
// Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph.
let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."}));
@@ -9309,13 +9497,14 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
assert!(relisted["paragraphs"][1]["text"].as_str().unwrap().starts_with("Charlie"));
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Charlie paragraph, edited."}));
let text = page_text(&mut a, doc).join(" ");
assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, a third row."), "{text}");
assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, second row."), "{text}");
std::fs::remove_dir_all(dir).unwrap();
}
/// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow:
/// the merged one holds the edited text too, but rewriting it by the edited paragraph's number
/// would drop the neighbour's text (#2516).
/// would drop the neighbour's text (#2516). Moving Alpha down (see the previous test) is the
/// regrouping edit, since a text rewrite is marked precisely so it cannot merge this way (#2517).
#[test]
fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
let dir = workdir("text-edit-merged-number");
@@ -9323,11 +9512,7 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
let first = ok(
&mut a,
"text_edit",
json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
);
let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}");
// Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused.
@@ -9341,6 +9526,8 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() {
/// The editor background preview takes the same paragraph number as `text_edit`, so it hides the
/// paragraph the number named in the listing and refuses a number an edit made stale (#2516).
/// Moving Alpha down (see the two tests above) is the regrouping edit; a text rewrite is marked
/// precisely so it cannot merge this way (#2517).
#[test]
fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() {
let dir = workdir("hide-text-block-stale-number");
@@ -9348,11 +9535,7 @@ fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrou
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1}));
ok(
&mut a,
"text_edit",
json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}),
);
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2}));
// Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie.
let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2}));
@@ -9574,6 +9757,49 @@ fn bookmark_list_reports_colour_and_style() {
assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved");
}
/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box
/// again. Text edits take `aligns` for a paragraph's rows the same way.
#[test]
fn added_text_lines_take_their_own_alignments() {
let dir = workdir("paragraph-aligns");
let mut a = auto(&dir);
let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
let rect = |a: &mut Automation, text: &str| -> Vec<f64> {
let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
};
assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
// The whole box centred: the right-aligned line goes with it.
ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
let right = rect(&mut a, "Right line");
assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
}
/// `aligns` alone on an existing paragraph aligns its rows by index, as the app does: the rows
/// stay rows instead of being rewrapped into one, where only the first entry would count.
#[test]
fn text_edit_aligns_alone_aligns_the_paragraphs_rows() {
let dir = workdir("paragraph-row-aligns");
let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET";
std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] }));
let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
let rect = |t: &str| -> Vec<f64> {
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == t).unwrap_or_else(|| panic!("{t:?} is a row: {lines}"));
line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
};
let (first, second) = (rect("The quick brown fox jumps over the lazy dog"), rect("and keeps running."));
assert!((first[0] - 72.0).abs() < 0.5, "the first row stays at the left: {first:?}");
assert!((second[2] - first[2]).abs() < 1.0 && second[0] > 100.0, "the second row is right-aligned: {second:?} vs {first:?}");
}
/// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its
/// options, and its page appearance shows it, before and after a save; one without Edit takes
/// only its options.
@@ -9723,3 +9949,225 @@ fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
}
/// #2517, through the same tools as the report. One blank line is two paragraphs, and editing
/// the first leaves the second. The neighbour at y=656.8 is one blank line below (two 14.4 pt
/// leadings under the second row). Growing the first paragraph, saving, reopening and editing
/// again leaves that neighbour in place. An explicit TL of 27.6 pt stays one paragraph.
#[test]
fn a_blank_line_stays_a_separate_paragraph_through_tools() {
let dir = workdir("blank-line");
let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET";
std::fs::write(dir.join("a.pdf"), helvetica_page(body)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let before = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
assert_eq!(before["count"], 2, "{before}");
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "First paragraph, edited row.\nFirst paragraph, second row." }));
let after = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
assert_eq!(after["count"], 2, "{after}");
let second = after["paragraphs"][1]["text"].as_str().unwrap();
assert!(second.contains("Second paragraph"), "{second}");
let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbour) Tj ET";
std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap();
let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2);
ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" }));
ok(&mut a, "doc_save", json!({ "doc": grown_doc, "path": "grown-out.pdf" }));
let saved = ok(&mut a, "doc_open", json!({ "path": "grown-out.pdf" }))["doc"].as_u64().unwrap();
let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
assert_eq!(after["count"], 2, "{after}");
assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "text": "One\nTwo\nFour" }));
let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }));
assert_eq!(after["count"], 2, "{after}");
assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}");
let spaced = "BT /F1 12 Tf 27.6 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET";
std::fs::write(dir.join("spaced.pdf"), helvetica_page(spaced)).unwrap();
let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1);
}
/// #2529, through the same tools as a width drag. The typed double space falls on the wrap.
/// Omitting `text` reflows with the saved gap count. An incremental save and a full rewrite
/// both still have both spaces after the box is widened again.
#[test]
fn narrowing_then_widening_keeps_a_typed_double_space_through_tools() {
let dir = workdir("double-space-width");
std::fs::write(dir.join("a.pdf"), helvetica_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET")).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]["editor_text"], "One Two");
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "width": 45.0 }));
let narrow = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0];
assert_eq!(narrow["editor_text"], "One\nTwo", "{narrow}");
for (name, full) in [("narrow-inc.pdf", false), ("narrow-full.pdf", true)] {
ok(&mut a, "doc_save", json!({ "doc": doc, "path": name, "full": full }));
let saved = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap();
let block = &ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }))["paragraphs"][0];
assert_eq!(block["editor_text"], "One\nTwo", "{name}: {block}");
ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "width": 200.0 }));
let wide_name = format!("wide-{name}");
ok(&mut a, "doc_save", json!({ "doc": saved, "path": wide_name, "full": full }));
let wide = ok(&mut a, "doc_open", json!({ "path": wide_name }))["doc"].as_u64().unwrap();
let block = &ok(&mut a, "text_paragraphs", json!({ "doc": wide, "page": 1 }))["paragraphs"][0];
assert_eq!(block["editor_text"], "One Two", "{name}: the second typed space was lost: {block}");
}
}
fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec<String> {
ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"]
.as_array()
.unwrap()
.iter()
.map(|p| p["text"].as_str().unwrap().to_string())
.collect()
}
/// The reported edits, through open / text_paragraphs / text_edit / save / reopen / text_extract.
/// Covers an explicit TL, the three ways a file moves rows without TL, a blank line written with
/// Td, a two-row file that must stay split, and a partial rewrite that adds a row.
#[test]
fn paragraph_grouping_survives_save_and_reopen_through_tools() {
let dir = workdir("paragraph-grouping");
let mut a = auto(&dir);
for step in [27.6_f64, 28.8] {
let y2 = 700.0 - step;
let y3 = 700.0 - 2.0 * step;
let bodies = [
format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"),
format!(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y2} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y3} Tm (Three) Tj ET"
),
format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {y2} Td (Two) Tj ET BT /F1 12 Tf 72 {y3} Td (Three) Tj ET"),
];
for (i, body) in bodies.iter().enumerate() {
let path = format!("step-{step}-{i}.pdf");
std::fs::write(dir.join(&path), helvetica_page(body)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
let paras = paragraph_texts(&mut a, doc);
assert_eq!(paras.len(), 1, "{path}: {paras:?}");
assert!(paras[0].contains("One") && paras[0].contains("Two") && paras[0].contains("Three"), "{path}: {paras:?}");
let extracted = page_text(&mut a, doc);
assert!(extracted[0].contains("One") && extracted[0].contains("Three"), "{path}: {extracted:?}");
}
}
// One gap and no third row: still two paragraphs.
let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - 27.6);
std::fs::write(dir.join("pair.pdf"), helvetica_page(&pair)).unwrap();
let pair_doc = ok(&mut a, "doc_open", json!({ "path": "pair.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, pair_doc).len(), 2);
// Blank line written with Td, not TL. The inner step repeats; the break does not.
let inner = 14.4_f64;
let blank = format!(
"BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \
BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET",
700.0 - inner,
700.0 - 3.0 * inner,
700.0 - 4.0 * inner
);
std::fs::write(dir.join("blank.pdf"), helvetica_page(&blank)).unwrap();
let blank_doc = ok(&mut a, "doc_open", json!({ "path": "blank.pdf" }))["doc"].as_u64().unwrap();
let blank_paras = paragraph_texts(&mut a, blank_doc);
assert_eq!(blank_paras.len(), 2, "{blank_paras:?}");
assert!(blank_paras[0].contains("One") && blank_paras[0].contains("Two"), "{blank_paras:?}");
assert!(blank_paras[1].contains("Neighbour") && blank_paras[1].contains("Line"), "{blank_paras:?}");
// The first row is unchanged. Save incremental, reopen, edit again, save in full, reopen.
let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET";
std::fs::write(dir.join("partial.pdf"), helvetica_page(partial)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "partial.pdf" }));
assert_eq!(incremental["incremental"], true, "{incremental}");
let reopened = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "partial-full.pdf", "full": true }));
assert_eq!(full["incremental"], false, "{full}");
let again = ok(&mut a, "doc_open", json!({ "path": "partial-full.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays."]);
let extracted = page_text(&mut a, again);
assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}");
}
/// Editing the only paragraph on the page, while leaving its second row in the source, stays
/// one paragraph through save and reopen. A neighbour in a different marked region stays in
/// its own paragraph.
#[test]
fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() {
let dir = workdir("paragraph-tail");
let mut a = auto(&dir);
let only = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET";
std::fs::write(dir.join("only.pdf"), helvetica_page(only)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row."]);
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row."]);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "only.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two."]);
let marked = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \
/P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC";
std::fs::write(dir.join("marked.pdf"), helvetica_page(marked)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row.", "Neighbor stays."]);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row.", "Neighbor stays."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]);
}
/// Both ends of one text object, through open / text_edit / save / reopen / text_extract.
/// Editing the last paragraph must not take the one before it, and editing the first must
/// not take the one after it. Incremental and full saves.
#[test]
fn a_shared_text_object_keeps_both_paragraphs_through_tools() {
let dir = workdir("shared-text-object");
let mut a = auto(&dir);
let body =
"BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET";
std::fs::write(dir.join("last.pdf"), helvetica_page(body)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."]);
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nChanged." }));
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "last.pdf" }));
assert_eq!(incremental["incremental"], true, "{incremental}");
let reopened = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nEdited." }));
let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "last-full.pdf", "full": true }));
assert_eq!(full["incremental"], false, "{full}");
let again = ok(&mut a, "doc_open", json!({ "path": "last-full.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]);
let extracted = page_text(&mut a, again);
assert!(extracted[0].contains("Alpha first row.") && extracted[0].contains("Edited."), "{extracted:?}");
std::fs::write(dir.join("first.pdf"), helvetica_page(body)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "first.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays. Neighbor next."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "first-full.pdf", "full": true }));
let again = ok(&mut a, "doc_open", json!({ "path": "first-full.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays. Neighbor next."]);
let extracted = page_text(&mut a, again);
assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}");
}
+23
View File
@@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn button_export_edit_is_one_undo_step_and_survives_save() {
let (mut a, id, dir) = setup("button-export");
tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]}));
tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}}));
let before = bytes(&a, id);
call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1}));
let f = tool(&mut a, "form_fields", json!({"doc": id}));
let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
assert_eq!(check["export_values"], json!(["Accepted"]));
assert_eq!(check["value"], true);
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
tool(&mut a, "edit_redo", json!({"doc": id}));
tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
let f = tool(&mut a, "form_fields", json!({"doc": reopened}));
let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
assert_eq!(check["export_values"], json!(["Accepted"]));
assert_eq!(check["value"], true);
std::fs::remove_dir_all(dir).unwrap();
}
+3
View File
@@ -15,6 +15,8 @@ pdfcraft-model = { workspace = true }
pdfcraft-fonts = { workspace = true }
# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
pdfcraft-xfa = { workspace = true }
# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images.
hayro-jbig2.workspace = true
png = "0.18"
tiff = "0.11"
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
@@ -29,3 +31,4 @@ encoding_rs = "0.8"
hayro-ccitt.workspace = true
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
hayro-syntax = { workspace = true }
pdfcraft-filters = { workspace = true }
+5
View File
@@ -27,6 +27,11 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included,
as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more
pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the
page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image
exported opaque, as JPX and CCITT masks are.
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
Missing, null or malformed Decode arrays keep the default mapping.
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
+116 -6
View File
@@ -1,7 +1,7 @@
//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages
//! use, as files. JPEG images are written unchanged; other images, and JPEG images with
//! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, separations) are
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, separations) are
//! reported, never silently left out.
use std::collections::HashSet;
@@ -100,6 +100,116 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
}
}
/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given
/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor
/// given so would otherwise be lost), or, for a JBIG2 image
/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples
/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7).
fn decoded(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
match filters(s).as_slice() {
[f] if f == b"JBIG2Decode" => jbig2(doc, s),
_ => doc.decoded_image(s).map_err(|e| e.to_string()),
}
}
/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2).
fn decode_parms(doc: &Document, s: &Stream) -> Dict {
let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() };
let p = match &*p {
Object::Array(a) => match a.first() {
Some(first) => doc.resolve(first),
None => return Dict::default(),
},
_ => p,
};
p.as_dict().cloned().unwrap_or_default()
}
/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder
/// shares between pages, themselves coded, so far less than any stream may decode to
/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image.
const MAX_GLOBALS: usize = 32 << 20;
/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller
/// checked against the export's size limit, and within that limit itself: the decoder decodes the
/// whole page, rows past the image's height too, so a small `/Height` over a far taller page
/// would cost more than the limit allows. Rows past the image's height are dropped, and too few
/// rows are refused by the caller. Globals that can't be read refuse the image, rather than
/// decoding it without them. They are decoded before the page's size is known, so the page's
/// limit can't bound them; they have [`MAX_GLOBALS`] of their own.
fn jbig2(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() {
Some(Object::Stream(g)) => {
Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?)
}
_ => None,
};
let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?;
if s.dict.int(b"Width") != Some(i64::from(image.width())) {
return Err("the JBIG2 image's width isn't the image's width".into());
}
if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS {
return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height()));
}
let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0);
let mut rows = Rows::new(image.width() as usize, height);
image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?;
Ok(rows.out)
}
/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns`
/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so
/// the output is bounded by the size the caller gave.
struct Rows {
out: Vec<u8>,
row: Vec<u8>,
x: usize,
columns: usize,
rows: usize,
done: usize,
}
impl Rows {
fn new(columns: usize, rows: usize) -> Self {
Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 }
}
/// `count` pixels, as 1 samples when `one`.
fn push(&mut self, one: bool, count: usize) {
let end = self.x.saturating_add(count);
if one {
for x in self.x..end.min(self.columns) {
if let Some(b) = self.row.get_mut(x / 8) {
*b |= 0x80 >> (x % 8);
}
}
}
self.x = end;
}
fn end_row(&mut self) {
if self.done < self.rows {
self.out.extend_from_slice(&self.row);
self.done += 1;
}
self.row.fill(0);
self.x = 0;
}
}
/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0.
impl hayro_jbig2::Decoder for Rows {
fn push_pixel(&mut self, black: bool) {
self.push(!black, 1);
}
fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
self.push(!black, (chunks as usize).saturating_mul(8));
}
fn next_line(&mut self) {
self.end_row();
}
}
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
/// alpha, JPEG images included, so cut-out images keep their transparent background.
@@ -108,7 +218,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
match f.last().map(Vec::as_slice) {
Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()),
Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()),
Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()),
Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()),
_ => {}
}
@@ -147,7 +257,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
space = decoded;
(samples, 8)
} else {
let data = doc.decoded_image(s).map_err(|e| e.to_string())?;
let data = decoded(doc, s)?;
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
1 => 1,
@@ -467,7 +577,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8
}
/// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000 and JBIG2 masks).
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks).
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true)));
@@ -477,7 +587,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8();
(img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())?
}
Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode") => return None,
Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None,
_ => {
let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
@@ -489,7 +599,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
_ => return None,
};
let max = (1u32 << bpc.min(8)) - 1;
unpack(&doc.decoded_image(m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
}
};
if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) {
+275
View File
@@ -1934,3 +1934,278 @@ fn group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture() {
assert_eq!(exported[0].1.1.get(..8), Some(&[255, 0, 0, 255, 0, 0, 255, 255][..]), "red, then blue");
assert_eq!(exported[1], exported[0], "by reference as direct");
}
/// An image whose PNG predictor is given by reference exports as its picture: its `/DecodeParms`
/// dictionary given by reference, as an array's entry, or direct with its numbers by reference. So
/// do an indexed image whose lookup table is a stream with its `/DecodeParms` given by reference,
/// and a JBIG2 image whose `/JBIG2Globals` stream has them so. `Stream::decoded` reads only a
/// direct `/DecodeParms` with direct numbers: the row tags stayed in the samples, and the picture
/// came out shifted.
#[test]
fn images_whose_predictor_is_given_by_reference_export_as_their_picture() {
// PNG predictor parameters for `columns` one-byte samples a row.
let parms = |columns: i64| {
let mut p = Dict::new();
p.set(b"Predictor".to_vec(), Object::Int(15));
p.set(b"Colors".to_vec(), Object::Int(1));
p.set(b"BitsPerComponent".to_vec(), Object::Int(8));
p.set(b"Columns".to_vec(), Object::Int(columns));
p
};
// Two rows of four grey samples, each behind a PNG "None" row tag.
let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]);
let grey: Vec<u8> = [10u8, 20, 30, 40, 50, 60, 70, 80].iter().flat_map(|v| [*v, *v, *v, 255]).collect();
for form in ["a reference", "an array of a reference", "numbers by reference"] {
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut d = image_dict(4, 2, "DeviceGray", 8);
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let decode_parms = match form {
"numbers by reference" => {
let mut p = parms(4);
for key in [&b"Predictor"[..], b"Columns"] {
let v = p.get(key).cloned().unwrap();
p.set(key.to_vec(), Object::Ref(doc.add(v)));
}
Object::Dict(p)
}
"a reference" => Object::Ref(doc.add(Object::Dict(parms(4)))),
_ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms(4))))]),
};
d.set(b"DecodeParms".to_vec(), decode_parms);
Stream::from_raw(d, predicted.clone())
});
assert_eq!(exported_rgba(&doc), ("png", grey.clone()), "/DecodeParms as {form}");
}
// An indexed image's lookup table, red then blue, as a stream behind a "None" row tag.
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut t = Dict::new();
t.set(b"Filter".to_vec(), Object::name("FlateDecode"));
t.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(6)))));
let table = doc.add(Object::Stream(Stream::from_raw(t, pdfcraft_filters::encode_flate(&[0, 255, 0, 0, 0, 0, 255]))));
let mut d = image_dict(4, 1, "DeviceGray", 8);
d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::Ref(table)]));
Stream::from_raw(d, vec![0, 1, 0, 1])
});
let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]);
assert_eq!(exported_rgba(&doc), ("png", [red, blue, red, blue].concat()), "the lookup table's /DecodeParms by reference");
// A JBIG2 image whose globals hold one extension segment (ISO/IEC 14492 7.4.14) a decoder
// ignores, behind a "None" row tag. With the tag left in, the globals can't be read and the
// image is skipped.
let segment = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut g = Dict::new();
g.set(b"Filter".to_vec(), Object::name("FlateDecode"));
g.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(segment.len() as i64)))));
let tagged: Vec<u8> = [0].iter().chain(&segment).copied().collect();
let globals = doc.add(Object::Stream(Stream::from_raw(g, pdfcraft_filters::encode_flate(&tagged))));
let mut decode_parms = Dict::new();
decode_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let mut d = image_dict(16, 2, "DeviceGray", 1);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms));
Stream::from_raw(d, pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap())
});
let picture: Vec<u8> = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect();
assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference");
}
/// An image the optimizer stored as JBIG2 symbols (a symbol dictionary and text regions, rather
/// than one generic region) exports as its picture.
#[test]
fn symbol_coded_jbig2_images_export_as_their_picture() {
const LETTERS: [[&str; 7]; 3] = [
["#####", "#....", "####.", "#....", "#....", "#....", "#####"],
[".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"],
];
let (w, h) = (400usize, 40usize);
let stride = w.div_ceil(8);
// 1 samples white, as a grey image's are; the letters' pixels 0.
let mut bits = vec![0xFFu8; stride * h];
for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() {
for (k, x) in (3..w - 6).step_by(7).enumerate() {
for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() {
for (dx, c) in row.bytes().enumerate() {
if c == b'#' {
bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
}
}
}
}
}
let jbig2 = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap();
let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
assert!(jbig2.len() < generic.len(), "symbol coded: {} bytes, generic {}", jbig2.len(), generic.len());
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |_| {
let mut d = image_dict(w as i64, h as i64, "DeviceGray", 1);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
Stream::from_raw(d, jbig2)
});
let picture: Vec<u8> = (0..w * h)
.flat_map(|i| if bits[i / w * stride + i % w / 8] & (0x80 >> (i % w % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] })
.collect();
assert_eq!(exported_rgba(&doc), ("png", picture));
}
/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as
/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in
/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the
/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image,
/// or larger than an exported image may be (however small the image's `/Height`), or data that
/// isn't JBIG2, skips the image.
#[test]
fn jbig2_images_export_as_their_picture() {
let (w, h) = (203i64, 41i64);
let stride = 26;
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let bits: Vec<u8> = (0..stride * h as usize)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect();
// The first `rows` rows as RGBA: a 1 sample white, a 0 black.
let picture = |rows: i64| -> Vec<u8> {
(0..rows as usize)
.flat_map(|y| (0..w as usize).map(move |x| (x, y)))
.flat_map(|(x, y)| {
let v = if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 };
[v, v, v, 255]
})
.collect()
};
let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
// A page whose image is `data` as JBIG2, `width` × `height`. With `globals`, its /DecodeParms
// (an array when `.1`) name a globals stream; with `mask`, it is the stencil /Mask of a grey
// image.
let page = |width: i64, height: i64, data: &[u8], globals: Option<(&[u8], bool)>, mask: bool| {
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut d = image_dict(width, height, "DeviceGray", 1);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
if let Some((g, in_array)) = globals {
let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), g.to_vec())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
d.set(b"DecodeParms".to_vec(), if in_array { Object::Array(vec![Object::Dict(parms)]) } else { Object::Dict(parms) });
}
if !mask {
return Stream::from_raw(d, data.to_vec());
}
d.remove(b"ColorSpace");
d.set(b"ImageMask".to_vec(), Object::Bool(true));
let m = doc.add(Object::Stream(Stream::from_raw(d, data.to_vec())));
let mut image = image_dict(width, height, "DeviceGray", 8);
image.set(b"Mask".to_vec(), Object::Ref(m));
Stream::from_raw(image, vec![128; (width * height) as usize])
});
doc
};
let skipped = |doc: &Document| {
let out = extract_images(doc, &[0], 0);
out.images.is_empty() && out.skipped.len() == 1
};
assert_eq!(exported_rgba(&page(w, h, &jbig2, None, false)), ("png", picture(h)));
assert_eq!(exported_rgba(&page(w, h, &jbig2, Some((&[][..], false)), false)).1, picture(h), "empty globals");
for in_array in [false, true] {
assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}");
}
assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height");
for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] {
assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len());
}
// The same stream with its page information (the first segment's data, after its 11-byte
// header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the
// export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page
// (it takes pages of up to about 2^28 pixels), so it is refused first.
let mut large = jbig2.clone();
large[11..15].copy_from_slice(&20_000u32.to_be_bytes());
large[15..19].copy_from_slice(&10_000u32.to_be_bytes());
assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size");
// Refused the same ways as a stencil /Mask, it is left out: the image exports opaque.
for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] {
let rgba = exported_rgba(&page(width, height, mask, None, true)).1;
assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask");
assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out");
}
// A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused.
for (rows, refused) in [(5_000u32, false), (5_001, true)] {
large[15..19].copy_from_slice(&rows.to_be_bytes());
let doc = page(20_000, 4, &large, None, false);
assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}");
if !refused {
assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4);
}
}
// Damaged data never panics.
for end in [0, 1, 10, 20, 30, jbig2.len() / 2] {
let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0);
}
// As a stencil /Mask, its 1 samples (JBIG2 white) hide the image.
let alpha: Vec<u8> = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect();
assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha);
}
/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have
/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to
/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes
/// in the file. Globals of exactly the limit are read, and the limit is the same through an
/// indirect `/DecodeParms`, in an array or not.
#[test]
fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
const LIMIT: usize = 32 << 20;
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
let picture: Vec<u8> = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect();
// Globals of `len` bytes, Flate-encoded: one extension segment (ISO/IEC 14492 7.4.14), which a
// decoder skips; its 11-byte header gives the length of the data after it.
let globals = |len: usize| {
let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0];
g.extend_from_slice(&u32::try_from(len - 11).unwrap().to_be_bytes());
g.extend_from_slice(&[0, 0, 0, 1]);
g.resize(len, 0);
pdfcraft_filters::encode_flate(&g)
};
// The image, its /DecodeParms naming `flated` as its globals: direct (0), by reference (1), or
// by reference in an array (2).
let page = |flated: &[u8], form: usize| {
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
with_image(&mut doc, |doc| {
let mut g = Dict::new();
g.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let g = doc.add(Object::Stream(Stream::from_raw(g, flated.to_vec())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
let parms = match form {
0 => Object::Dict(parms),
1 => Object::Ref(doc.add(Object::Dict(parms))),
_ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms)))]),
};
let mut d = image_dict(16, 2, "DeviceGray", 1);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
d.set(b"DecodeParms".to_vec(), parms);
Stream::from_raw(d, jbig2.clone())
});
doc
};
let (small, full, over) = (globals(15), globals(LIMIT), globals(LIMIT + 1));
assert!(over.len() < 64 << 10, "the oversized globals take {} bytes in the file", over.len());
assert_eq!(exported_rgba(&page(&full, 0)).1, picture, "globals of exactly the limit");
for form in 0..3 {
assert_eq!(exported_rgba(&page(&small, form)).1, picture, "small globals, form {form}");
let out = extract_images(&page(&over, form), &[0], 0);
assert!(out.images.is_empty(), "globals over the limit, form {form}");
let why = &out.skipped.first().expect("the image is skipped").2;
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
}
}
+18
View File
@@ -67,5 +67,23 @@ impl Content {
}
}
fn align_code(a: Align) -> i64 {
match a {
Align::Left => 0,
Align::Center => 1,
Align::Right => 2,
Align::Justify => 3,
}
}
fn align_from(code: Option<i64>) -> Align {
match code {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
_ => Align::Left,
}
}
#[cfg(test)]
mod arabic_tests;
+2 -2
View File
@@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster {
fn line(clusters: Vec<ShapedCluster>, rtl: bool) -> LaidLine {
let text = clusters.iter().map(|c| c.text.as_str()).collect();
let width = clusters.iter().map(|c| c.advance).sum::<f64>() * item().size;
(text, rtl, vec![Piece::Shaped(clusters)], width)
(text, rtl, 0, vec![Piece::Shaped(clusters)], width)
}
/// A glyph outline: a box on the baseline, `advance` em wide.
@@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let mixed: LaidLine =
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
let laid = vec![mixed, rtl];
let mut doc = Document::new_empty();
+38 -36
View File
@@ -1,10 +1,10 @@
use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped,
};
use super::types::{AddedText, Align, Family};
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
literal, matrix_number, n, shaped_glyph, win_ansi,
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
};
fn font_name(base: &str) -> String {
@@ -16,8 +16,26 @@ fn font_name(base: &str) -> String {
pub(super) const GLYPHS_PER_FONT: usize = 240;
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
/// its pieces in drawing order and its width at the item's size.
pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
/// its paragraph, its pieces in drawing order and its width at the item's size.
pub(super) type LaidLine = (String, bool, usize, Vec<Piece>, f64);
/// Where line `i` (`w` wide unstretched, with `spaces` spaces) starts in the box `r`, and its word
/// spacing. `paragraphs` holds each line's paragraph: a line takes its paragraph's alignment, and
/// every line of a justified paragraph but its last is stretched to the box's width.
fn placed(t: &AddedText, r: [f64; 4], paragraphs: &[usize], i: usize, w: f64, spaces: usize, rtl: bool) -> (f64, f64) {
let paragraph = paragraphs.get(i).copied().unwrap_or(0);
let align = t.paragraph_align(paragraph);
let ends_paragraph = paragraphs.get(i + 1) != Some(&paragraph);
let tw = if align == Align::Justify && !ends_paragraph && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
let x = match align {
// A right-to-left paragraph's unstretched last line ends at the right edge.
Align::Justify if rtl => r[2] - w - tw * spaces as f64,
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
(x, tw)
}
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
@@ -25,7 +43,7 @@ pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
let laid = wrapped(t)
.into_iter()
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
.map(|(line, rtl, paragraph)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, paragraph, pieces, w)))
.collect::<Result<Vec<_>, _>>()?;
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
}
@@ -47,7 +65,7 @@ pub(super) fn draw_laid_shaped(
// 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 {
for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p {
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
@@ -92,21 +110,14 @@ pub(super) fn draw_laid_shaped(
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
let paragraphs: Vec<usize> = laid.iter().map(|(_, _, p, ..)| *p).collect();
for (i, (line, rtl, _, pieces, w)) in laid.iter().enumerate() {
// Shaped glyphs are painted in visual order. Keep the original line's typing order
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
let spaces = line.matches(' ').count();
// Justified: the spaces of every line but the last stretch to fill the box.
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
let x = match t.align {
// A right-to-left paragraph's unstretched last line ends at the right edge.
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
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;
@@ -287,20 +298,14 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
let all = lines(t);
for (i, line) in all.iter().enumerate() {
let all = wrapped(t);
let paragraphs: Vec<usize> = all.iter().map(|(_, _, p)| *p).collect();
for (i, (line, ..)) in all.iter().enumerate() {
let w = latin_width(t, line);
let x = match t.align {
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
// Baseline: 0.8 em below the line top.
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
// Justified: word spacing makes every line but the last fill the box.
let spaces = line.matches(' ').count();
let tw =
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
// Justified: word spacing makes every line of a paragraph but its last fill the box.
let (x, tw) = placed(t, r, &paragraphs, i, w, line.matches(' ').count(), false);
out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes());
out.extend(literal(&win_ansi(line)));
out.extend_from_slice(b" Tj\n");
@@ -350,15 +355,12 @@ pub(super) fn params(c: &Content) -> Dict {
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
d.set(b"Size".to_vec(), Object::Real(t.size));
d.set(b"Color".to_vec(), arr(&t.color));
d.set(
b"Align".to_vec(),
Object::Int(match t.align {
Align::Left => 0,
Align::Center => 1,
Align::Right => 2,
Align::Justify => 3,
}),
);
d.set(b"Align".to_vec(), Object::Int(align_code(t.align)));
// Each paragraph's own alignment, or null where it follows /Align.
let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align);
if !aligns.is_empty() {
d.set(b"Aligns".to_vec(), Object::Array(aligns.iter().map(|a| a.map_or(Object::Null, |a| Object::Int(align_code(a)))).collect()));
}
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
if t.char_spacing != 0.0 {
d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing));
+8 -8
View File
@@ -20,7 +20,7 @@ pub(super) fn latin_width(t: &AddedText, s: &str) -> f64 {
/// The lines of a text item after wrapping to its box width.
pub fn lines(t: &AddedText) -> Vec<String> {
wrapped(t).into_iter().map(|(line, _)| line).collect()
wrapped(t).into_iter().map(|(line, ..)| line).collect()
}
#[cfg(test)]
@@ -30,13 +30,13 @@ thread_local! {
}
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
/// does).
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
/// does) and which paragraph it is in.
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
#[cfg(test)]
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = (t.rect[2] - t.rect[0]).max(t.size);
let mut out = Vec::new();
for para in t.text.split('\n') {
for (index, para) in t.text.split('\n').enumerate() {
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
let thai = para.chars().any(is_thai);
@@ -44,7 +44,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
&& !rtl_script
&& let Ok(lines) = thai_lines(t, para, width)
{
out.extend(lines.into_iter().map(|line| (line, false)));
out.extend(lines.into_iter().map(|line| (line, false, index)));
continue;
}
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
@@ -60,7 +60,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
let candidate_w =
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl));
out.push((std::mem::take(&mut line), rtl, index));
line = word.to_string();
line_w = measure(word);
} else {
@@ -68,7 +68,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
line_w = candidate_w;
}
}
out.push((line, rtl));
out.push((line, rtl, index));
}
out
}
@@ -142,7 +142,7 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
}
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
wrapped(t).into_iter().find_map(|(line, rtl)| {
wrapped(t).into_iter().find_map(|(line, rtl, _)| {
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
pieces.iter().find_map(|p| match p {
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
+16 -9
View File
@@ -1,7 +1,7 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged};
use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged};
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
let r = nums(doc, d.get(b"Rect"));
@@ -10,20 +10,27 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
b"Text" => {
let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));
let c = nums(doc, d.get(b"Color"));
let text = d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
let align = align_from(d.int(b"Align"));
// At most one entry per paragraph, whatever the file says.
let aligns = d
.get(b"Aligns")
.and_then(|a| {
doc.resolve(a)
.as_array()
.map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect())
})
.unwrap_or_default();
Some(Content::Text(AddedText {
rect,
text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(),
text,
family,
bold,
italic,
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
align: match d.int(b"Align") {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
_ => Align::Left,
},
align,
aligns: crate::paragraph_align::tidy(aligns, align),
// Items written before character spacing have none.
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
}))
+11
View File
@@ -104,6 +104,16 @@ pub struct AddedText {
pub align: Align,
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
pub char_spacing: f64,
/// Paragraphs (lines typed with Enter) aligned other than [`AddedText::align`], by index;
/// `None` and paragraphs past the end follow `align` ([`crate::paragraph_align`]).
pub aligns: Vec<Option<Align>>,
}
impl AddedText {
/// Paragraph `i`'s alignment.
pub fn paragraph_align(&self, i: usize) -> Align {
crate::paragraph_align::align_of(&self.aligns, i, self.align)
}
}
impl Default for AddedText {
@@ -117,6 +127,7 @@ impl Default for AddedText {
size: 12.0,
color: [0.0; 3],
align: Align::Left,
aligns: Vec::new(),
char_spacing: 0.0,
}
}
+4 -5
View File
@@ -186,11 +186,10 @@ impl ImageState {
self.path.curve_to(self.point(x1, y1), end, end);
}
}
b"h"
// ClosePath with no subpath panics by the same contract (#3581).
if self.path.elements().last().is_some() => {
self.path.close_path();
}
// ClosePath with no subpath panics by the same contract (#3581).
b"h" if self.path.elements().last().is_some() => {
self.path.close_path();
}
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
self.path.move_to(self.point(x, y));
+1
View File
@@ -47,6 +47,7 @@ pub use images::{ImageChange, PageImage, change_image, page_images, reading_imag
pub mod bidi;
pub mod indic;
mod linebreak;
pub mod paragraph_align;
pub mod text;
pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
+174
View File
@@ -0,0 +1,174 @@
//! Alignment paragraph by paragraph. A paragraph is the text between two hard line breaks
//! (`\n`): a line typed with Enter in added text, or a source row of an edited paragraph. Each can
//! have an alignment of its own; `None` (or no entry) follows the box's or paragraph's alignment.
use std::ops::RangeInclusive;
use crate::added::Align;
/// How many paragraphs `text` has (an empty text has one).
pub fn count(text: &str) -> usize {
text.bytes().filter(|b| *b == b'\n').count().saturating_add(1)
}
/// The paragraph the byte position `at` of `text` is in.
fn paragraph_at(text: &str, at: usize) -> usize {
let bytes = text.as_bytes();
bytes.get(..at.min(bytes.len())).unwrap_or_default().iter().filter(|b| **b == b'\n').count()
}
/// The paragraphs a selection (byte range) of `text` touches. An empty selection is the caret's
/// paragraph; a selection ending just after a line break doesn't reach the next paragraph.
pub fn selected(text: &str, range: std::ops::Range<usize>) -> RangeInclusive<usize> {
let first = paragraph_at(text, range.start);
let last = if range.end > range.start { paragraph_at(text, range.end - 1) } else { first };
first..=last.max(first)
}
/// Paragraph `i`'s alignment: its own, or `base`.
pub fn align_of(aligns: &[Option<Align>], i: usize, base: Align) -> Align {
aligns.get(i).copied().flatten().unwrap_or(base)
}
/// Give paragraphs `which` of a text with `count` paragraphs the alignment `align`; the others
/// keep theirs. With a `base`, entries equal to it become `None` ([`tidy`]), so equal alignments
/// compare equal however they were set; without one (the base is only guessed, as for an edited
/// paragraph's own rows) every entry set stays. Trailing `None`s are dropped either way.
pub fn set(aligns: &[Option<Align>], count: usize, which: RangeInclusive<usize>, align: Align, base: Option<Align>) -> Vec<Option<Align>> {
let mut out: Vec<Option<Align>> = (0..count).map(|i| aligns.get(i).copied().flatten()).collect();
for i in which {
if let Some(a) = out.get_mut(i) {
*a = Some(align);
}
}
match base {
Some(base) => tidy(out, base),
None => trimmed(out),
}
}
/// `aligns` without entries that only repeat `base`, and without trailing `None`s.
pub fn tidy(mut aligns: Vec<Option<Align>>, base: Align) -> Vec<Option<Align>> {
for a in &mut aligns {
if *a == Some(base) {
*a = None;
}
}
trimmed(aligns)
}
fn trimmed(mut aligns: Vec<Option<Align>>) -> Vec<Option<Align>> {
while aligns.last() == Some(&None) {
aligns.pop();
}
aligns
}
/// Carry the alignments of `old`'s paragraphs over to `new`: the paragraphs before and after
/// the change keep theirs, and the paragraphs the change made take the alignment of the first
/// paragraph it changed, or for lines only inserted, of the paragraph before them (so Enter in or
/// after a centred paragraph makes centred ones).
pub fn remap(old: &str, new: &str, aligns: &[Option<Align>]) -> Vec<Option<Align>> {
remap_at(old, new, aligns, None)
}
/// [`remap`] for an edit that left the caret at byte `caret` of `new`. Repeated paragraphs can
/// make where the edit was ambiguous ("A\nA" to "A" deletes either one); the caret, at the edit's
/// end, says which. It only chooses between readings that fit the texts, so a caret away from the
/// edit never makes it an edit of other paragraphs.
pub fn remap_at(old: &str, new: &str, aligns: &[Option<Align>], caret: Option<usize>) -> Vec<Option<Align>> {
if aligns.is_empty() || old == new {
return aligns.to_vec();
}
let (o, n): (Vec<&str>, Vec<&str>) = (old.split('\n').collect(), new.split('\n').collect());
let shortest = o.len().min(n.len());
let same_start = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest);
let same_end = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest);
let fewest_kept = shortest - same_start;
let (prefix, suffix) = match caret {
Some(at) if fewest_kept <= same_end => {
// The edit's paragraphs of `new` end before the caret's paragraph when the caret starts
// one, and with it otherwise.
let starts = at == 0 || at.checked_sub(1).and_then(|i| new.as_bytes().get(i)) == Some(&b'\n');
let end = paragraph_at(new, at).saturating_add(usize::from(!starts));
let suffix = n.len().saturating_sub(end).clamp(fewest_kept, same_end);
(shortest - suffix, suffix)
}
_ => (same_start, same_end.min(shortest - same_start)),
};
let at = |i: usize| aligns.get(i).copied().flatten();
let inserted_only = o.len() - prefix - suffix == 0;
let changed = if inserted_only && prefix > 0 { at(prefix - 1) } else { at(prefix) };
let mut out: Vec<Option<Align>> = (0..prefix).map(at).collect();
out.extend(std::iter::repeat_n(changed, n.len() - prefix - suffix));
out.extend((o.len() - suffix..o.len()).map(at));
trimmed(out)
}
#[cfg(test)]
mod tests {
use super::*;
use Align::*;
#[test]
fn a_selection_names_the_paragraphs_it_touches() {
let text = "One\nTwo\nThree";
assert_eq!(count(text), 3);
assert_eq!(count(""), 1);
assert_eq!(selected(text, 0..0), 0..=0, "the caret's paragraph");
assert_eq!(selected(text, 5..6), 1..=1);
assert_eq!(selected(text, 2..9), 0..=2);
assert_eq!(selected(text, 4..8), 1..=1, "a whole line with its break stays on that line");
assert_eq!(selected(text, 0..999), 0..=2, "past the end is clamped");
// Not a character boundary: still counted by bytes, never a panic.
assert_eq!(selected("é\nb", 1..1), 0..=0);
}
#[test]
fn setting_one_paragraph_keeps_the_others_and_tidies() {
let one = set(&[], 3, 1..=1, Center, Some(Left));
assert_eq!(one, [None, Some(Center)]);
assert_eq!(align_of(&one, 0, Left), Left);
assert_eq!(align_of(&one, 1, Left), Center);
assert_eq!(align_of(&one, 9, Right), Right);
assert_eq!(set(&one, 3, 1..=1, Left, Some(Left)), [], "back to the box's alignment is no entry at all");
assert_eq!(set(&one, 3, 2..=7, Right, Some(Left)), [None, Some(Center), Some(Right)], "paragraphs past the end are ignored");
assert_eq!(set(&[Some(Right); 9], 2, 0..=0, Center, Some(Left)), [Some(Center), Some(Right)], "entries past the text are dropped");
assert_eq!(set(&[], 3, 0..=0, Left, None), [Some(Left)], "without a base, an explicit entry stays");
}
#[test]
fn alignments_follow_their_paragraphs_through_an_edit() {
let aligns = [Some(Center), None, Some(Right)];
assert_eq!(remap("A\nB\nC", "A\nB\nC", &aligns), aligns);
// A line inserted before the last keeps the last right-aligned.
assert_eq!(remap("A\nB\nC", "A\nB\nnew\nC", &aligns), [Some(Center), None, None, Some(Right)]);
// Enter inside the centred first paragraph: both halves are centred.
assert_eq!(remap("Ab\nB\nC", "A\nb\nB\nC", &aligns), [Some(Center), Some(Center), None, Some(Right)]);
// The middle paragraph deleted.
assert_eq!(remap("A\nB\nC", "A\nC", &aligns), [Some(Center), Some(Right)]);
// Everything replaced takes the first changed paragraph's alignment.
assert_eq!(remap("A\nB", "x", &[None, Some(Right)]), []);
assert_eq!(remap("A\nA", "A", &[Some(Center), Some(Right)]), [Some(Center)]);
assert_eq!(remap("A", "A\nA", &[Some(Center)]), [Some(Center), Some(Center)]);
}
/// Repeated rows make the edit's place ambiguous: "A\nA" to "A" deletes either row. The caret,
/// where the edit ended, settles which (CodeRabbit on 648dd428).
#[test]
fn the_caret_says_which_of_two_same_rows_an_edit_took() {
let aligns = [Some(Center), Some(Right)];
assert_eq!(remap_at("A\nA", "A", &aligns, Some(0)), [Some(Right)], "the first row and its break deleted");
assert_eq!(remap_at("A\nA", "A", &aligns, Some(1)), [Some(Center)], "the break and the second row deleted");
assert_eq!(remap_at("A\nA", "A", &aligns, None), [Some(Center)], "no caret: as before");
// Enter at the start of the first of two same rows: the new row is before it.
assert_eq!(remap_at("A\nA", "\nA\nA", &aligns, Some(1)), [Some(Center), Some(Center), Some(Right)]);
// Enter at the end of the second: the new row follows it.
assert_eq!(remap_at("A\nA", "A\nA\n", &aligns, Some(4)), [Some(Center), Some(Right), Some(Right)]);
// A caret away from the edit never makes it an edit of other rows.
assert_eq!(remap_at("A\nB\nC", "A\nB\nCx", &[Some(Center), Some(Right), None], Some(0)), [Some(Center), Some(Right)]);
// A caret past the end, or not on a character boundary, is no panic.
assert_eq!(remap_at("A\nA", "A", &aligns, Some(99)), [Some(Center)]);
assert_eq!(remap_at("é\né", "é", &aligns, Some(1)), [Some(Center)]);
}
}
File diff suppressed because it is too large. Load diff
+49 -18
View File
@@ -24,7 +24,7 @@ use crate::EditError;
mod regions;
#[path = "text_session.rs"]
mod session;
use regions::{group_blocks, is_centred, rewrite_styled_block};
use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block};
pub use session::TextSession;
mod blocks;
@@ -118,6 +118,14 @@ pub struct TextLine {
/// `ink`, and whether joining put a space before it. Empty when the line's text was put
/// into another order (see [`TextLine::runs`]).
runs: Vec<(String, usize, bool)>,
/// This line continues the previous one because a rewrite wrapped it (`/PCSoft`).
soft_break: bool,
/// This line starts after a break a rewrite kept on purpose (`/PCHard`).
hard_break: bool,
/// When `soft_break`: the exact number of separator characters the wrap consumed (0 for a
/// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none
/// (#2529). Meaningless otherwise.
soft_gap: usize,
}
impl TextLine {
@@ -154,6 +162,7 @@ mod tests {
lines: Vec::new(),
rows: Vec::new(),
editor_text: text.into(),
reflow_text: text.into(),
spans: Vec::new(),
invisible: false,
}
@@ -204,40 +213,62 @@ mod tests {
assert!(!same_base_font("Arial", ""));
}
fn wrapped(text: &str, width: f64) -> Vec<String> {
wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _, _)| line).collect()
}
#[test]
fn paragraph_wrapping_keeps_ideographic_spaces() {
assert_eq!(wrapped("A  B C", 4.0), ["A  B", "C"]);
assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]);
assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]);
// `wrap` treats every whitespace character, including `\r`, as a separator to keep
// (#4199); the caller turns `\r\n` and `\r` into `\n` before calling it.
assert_eq!(wrapped(" \tA B\r\nC ", 3.0), [" A", "B ", "C "]);
}
#[test]
fn a_wrap_is_marked_soft_and_a_newline_is_not() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]);
assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false, 0), ("1".into(), true, 1)]);
assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false, 0), ("Two".into(), false, 0)]);
}
/// #2529: a run of spaces that happens to fall at a wrap point is recorded exactly, not
/// assumed to be one space; a Chinese/Japanese word split, which has no space of its own,
/// is recorded as none.
#[test]
fn a_wrap_records_how_many_spaces_it_consumed() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 2)]);
assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 3)]);
assert_eq!(wrap("日本語の文章です", 3.0, width), [("日本語".into(), false, 0), ("の文章".into(), true, 0), ("です".into(), true, 0)]);
}
/// #4199: a run of spaces stays a run, before, between and after words; the spaces where a
/// row wraps go, and each other white-space character is one space.
#[test]
fn paragraph_wrapping_keeps_every_space_typed() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("a b c", 20.0, width), ["a b c"]);
assert_eq!(wrap(" a b ", 20.0, width), [" a b "]);
assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]);
assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]);
assert_eq!(wrapped("a b c", 20.0), ["a b c"]);
assert_eq!(wrapped(" a b ", 20.0), [" a b "]);
assert_eq!(wrapped("a\t\tb", 20.0), ["a b"]);
assert_eq!(wrapped("aa bb cc", 6.0), ["aa bb", "cc"]);
// An indent alone on a row doesn't wrap before its first word.
assert_eq!(wrap(" abc", 3.0, width), [" abc"]);
assert_eq!(wrapped(" abc", 3.0), [" abc"]);
// The rewrite turns `\r\n` and `\r` into `\n` before wrapping.
assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]);
assert_eq!(wrap(" ", 3.0, width), [" "]);
assert_eq!(wrap("", 3.0, width), [""]);
assert_eq!(wrapped(" \tA B\nC ", 3.0), [" A", "B", "C "]);
assert_eq!(wrapped(" ", 3.0), [" "]);
assert_eq!(wrapped("", 3.0), [""]);
}
#[test]
fn japanese_text_wraps_between_characters_but_not_before_closing_marks() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]);
assert_eq!(wrapped("日本語の文章です。", 3.0), ["日本語", "の文章", "です。"]);
// The comma can't begin a row, so the character before it moves down with it.
assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]);
assert_eq!(wrapped("あいう、えお", 3.0), ["あい", "う、え", "お"]);
// Pieces of one word join without a space; separate words keep theirs.
assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]);
assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]);
assert_eq!(wrapped("Edit PDFを編集", 6.0), ["Edit", "PDFを編集"]);
assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]);
}
#[test]
+23 -5
View File
@@ -25,6 +25,9 @@ pub struct TextBlock {
pub rows: Vec<Vec<usize>>,
/// Text for the inline editor, with a newline only between visual rows.
pub editor_text: String,
/// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a
/// short line that still had room for the next word, stays a newline.
pub reflow_text: String,
/// Byte ranges in `editor_text` and their source line indexes.
pub spans: Vec<(std::ops::Range<usize>, usize)>,
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
@@ -337,11 +340,18 @@ pub(crate) fn paragraph_separator(c: char) -> bool {
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
/// those pieces join without a space. Every white-space character is written as one space, so
/// a run of spaces typed between, before or after words stays (#4199); only the spaces where a
/// row wraps are dropped.
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
/// row wraps are dropped (recorded as the third value, so a later rejoin can restore the exact
/// count instead of guessing one, #2529). The bool is true when the line continues the previous
/// one because the next word did not fit, rather than because of a newline; the number is how
/// many separator characters stood where that wrap fell (0 for a Chinese/Japanese word split,
/// which has no space of its own; meaningless when the bool is false).
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool, usize)> {
let mut out = Vec::new();
for row in text.split('\n') {
let mut line = String::new();
let mut soft = false;
// The gap consumed right before `line` started: 0 until a wrap sets it.
let mut wrap_gap = 0usize;
let start = out.len();
// The separators read since the last word: each one boundary between `split` pieces.
let mut gap = 0usize;
@@ -352,12 +362,17 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec
if word.is_empty() {
continue;
}
let spaces = " ".repeat(std::mem::take(&mut gap));
let gap_here = std::mem::take(&mut gap);
let spaces = " ".repeat(gap_here);
for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() {
let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") };
// A row holding only the spaces that indent it doesn't wrap before its first word.
if line.chars().any(|c| c != ' ') && advance(&candidate) > width {
out.push(std::mem::take(&mut line));
out.push((std::mem::take(&mut line), soft, wrap_gap));
soft = true;
// A mid-word piece (i > 0) had no separator of its own; the first piece of a
// word had exactly the separators just taken.
wrap_gap = if i == 0 { gap_here } else { 0 };
line = piece.to_string();
} else {
line = candidate;
@@ -366,7 +381,7 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec
}
line.push_str(&" ".repeat(gap));
if !line.is_empty() || out.len() == start {
out.push(line);
out.push((line, soft, wrap_gap));
}
}
out
@@ -395,6 +410,9 @@ pub struct BlockStyle {
/// Fill colour (RGB 0–1).
pub color: Option<[f64; 3]>,
pub align: Option<crate::added::Align>,
/// Rows (the text's lines between `\n`s) aligned other than the paragraph, by index; `None`
/// and rows past the end follow `align` ([`crate::paragraph_align`]).
pub aligns: Vec<Option<crate::added::Align>>,
/// Draw a line under each line of text.
pub underline: Option<bool>,
/// Line spacing as a multiple of the font size (1.2 is ordinary).
+58 -1
View File
@@ -301,6 +301,14 @@ pub(super) struct Shown {
pub(super) paint: Paint,
pub(super) logical_order: bool,
pub(super) actual_text: bool,
/// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line.
pub(super) soft_break: bool,
pub(super) hard_break: bool,
/// The `/N` a `/PCSoft` marker carried: how many separator characters the wrap consumed (0
/// for a Chinese/Japanese word split). Meaningless unless `soft_break` is set; 1 for a
/// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer),
/// matching the single space a rejoin always used before #2529.
pub(super) soft_gap: usize,
}
impl Shown {
@@ -549,6 +557,9 @@ struct TextRunState<'a> {
marked: &'a MarkedContent,
carry: &'a mut Carry,
out: &'a mut Vec<Shown>,
soft_break: bool,
hard_break: bool,
soft_gap: usize,
}
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
@@ -745,6 +756,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
soft_break: state.soft_break,
hard_break: state.hard_break,
soft_gap: state.soft_gap,
});
Ok(())
}
@@ -769,11 +783,34 @@ pub(super) fn interpret(
let mut bt = 0usize;
let mut marked =
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
// A rewrite names a wrap (`/PCSoft`) or a break it kept (`/PCHard`) in an empty marker
// before the next line. Neither name is a paragraph region, so it does not change scope.
let mut soft_break = false;
let mut hard_break = false;
// `/PCSoft`'s `/N` property: how many separator characters the wrap consumed. A `/PCSoft`
// with no properties dict (a file saved before this was tracked, or by another writer) reads
// as 1, the single space a rejoin always used before #2529.
let mut soft_gap = 1usize;
for (i, op) in ops.iter().enumerate() {
match op.op.as_slice() {
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
// sequence (#3573). Both stacks stay bounded by the number of operators.
b"BMC" | b"BDC" => {
if op.name(0) == Some(b"PCSoft") {
soft_break = true;
hard_break = false;
soft_gap = op
.operands
.get(1)
.and_then(Object::as_dict)
.and_then(|d| d.get(b"N"))
.and_then(Object::as_int)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(1);
} else if op.name(0) == Some(b"PCHard") {
hard_break = true;
soft_break = false;
}
marked.begin(doc, op, i, logical_text, out.len());
}
b"EMC" => {
@@ -878,8 +915,28 @@ pub(super) fn interpret(
tm = tlm;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
let before = out.len();
let mut state = TextRunState {
ts: &mut ts,
tm: &mut tm,
tlm: &mut tlm,
at_bt: &at_bt,
bt,
marked: &marked,
carry,
out: &mut out,
soft_break,
hard_break,
soft_gap,
};
show_text(op, i, &mut state)?;
// A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break
// belongs to whichever text-showing operator comes next.
if out.len() > before {
soft_break = false;
hard_break = false;
soft_gap = 1;
}
}
_ => {}
}
+6
View File
@@ -280,6 +280,9 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
clips: s.state.render_mode >= 4,
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
runs: vec![(s.text.clone(), s.ink.len(), false)],
soft_break: s.soft_break,
hard_break: s.hard_break,
soft_gap: s.soft_gap,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -394,6 +397,9 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
clips: s.state.render_mode >= 4,
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
runs: vec![(s.text.clone(), s.ink.len(), false)],
soft_break: s.soft_break,
hard_break: s.hard_break,
soft_gap: s.soft_gap,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
+73 -18
View File
@@ -6,8 +6,8 @@ use super::fallback::{
use super::interp::{Carry, Origin, TextState, interpret};
use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines};
use super::{
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, group_blocks, is_centred, parse,
rewrite_styled_block,
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, alignment_frame, group_blocks,
is_centred, parse, rewrite_styled_block,
};
/// What replacing a line did.
@@ -501,7 +501,6 @@ fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64,
/// Alignment: each line's offset from the left edge.
struct Alignment {
align: Option<crate::added::Align>,
width: f64,
k: f64,
anchored_right: bool,
@@ -510,10 +509,11 @@ struct Alignment {
}
impl Alignment {
/// `visible` is the width the line is aligned on, in user space; the offset is in text space.
fn offset(&self, visible: f64) -> f64 {
/// `visible` is the width the line is aligned on, in user space, and `align` its row's
/// alignment; the offset is in text space.
fn offset(&self, visible: f64, align: Option<crate::added::Align>) -> f64 {
let free = (self.width - visible).max(0.0) / self.k;
match self.align {
match align {
Some(crate::added::Align::Center) => free / 2.0,
// A right-to-left line anchored at its right edge may start left of the old box.
Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k,
@@ -531,7 +531,9 @@ struct LineWriter<'a> {
alignment: Alignment,
encode: &'a Encoder,
source: Option<&'a Metrics>,
justify: bool,
/// The paragraph's alignment, and the rows aligned on their own ([`BlockStyle::aligns`]).
align: Option<crate::added::Align>,
aligns: &'a [Option<crate::added::Align>],
/// Whether `Tw` reaches every shown space.
word_spacing_reaches_spaces: bool,
width: f64,
@@ -551,13 +553,18 @@ struct LineCursor {
}
impl LineWriter<'_> {
/// Row `row`'s alignment: its own, or the paragraph's.
fn align_of(&self, row: usize) -> Option<crate::added::Align> {
self.aligns.get(row).copied().flatten().or(self.align)
}
/// Justify: each line but the last fills the width. A stretched line shows only its words, as
/// before #4199: word spacing would reach the spaces before its first word and after its
/// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't
/// justified.
fn stretch(&self, body: &str, last: bool) -> f64 {
fn stretch(&self, body: &str, last: bool, justify: bool) -> f64 {
let interior = body.matches(' ').count();
if self.justify && !last && interior > 0 && self.scale.abs() > 1e-6 {
if justify && !last && interior > 0 && self.scale.abs() > 1e-6 {
Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0)
} else {
0.0
@@ -578,14 +585,41 @@ impl LineWriter<'_> {
}
}
/// Writes one wrapped line, returning its underline (x0, x1, y) in text space.
fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<(f64, f64, f64), EditError> {
/// Writes one wrapped line of its logical draft row, returning its underline (x0, x1, y) in text space. `wrap`: whether
/// this line continues the previous one because a word did not fit, and, when it does, how
/// many separator characters the wrap consumed (0 for a Chinese/Japanese word split).
fn write_line(
&self,
index: usize,
line: &str,
wrap: (usize, bool, usize),
last: bool,
cursor: &mut LineCursor,
ops: &mut Vec<Op>,
) -> Result<(f64, f64, f64), EditError> {
let n = pdfcraft_content::num;
let (row, soft, gap) = wrap;
let line_align = self.align_of(row);
let body = line.trim_matches(' ');
let interior = body.matches(' ').count();
let tw = self.stretch(body, last);
let tw = self.stretch(body, last, line_align == Some(crate::added::Align::Justify));
let (shown, visible) = self.shown(line, body, tw);
let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) };
let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible, line_align) };
if index > 0 {
// Inside this text object, so the marker starts and ends here. `/PCHard` is a newline
// that was already in the text. `/PCSoft` rejoins when the box widens, with `/N` the
// exact number of separator characters the wrap consumed (0 for a Chinese/Japanese
// word split), so a rejoin restores what was actually there instead of guessing a
// single space and silently dropping the rest of a typed run (#2529).
if soft {
let mut properties = Dict::new();
properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX)));
ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)]));
} else {
ops.push(Op::new("BMC", vec![Object::name("PCHard")]));
}
ops.push(Op::new("EMC", vec![]));
}
if index > 0 || dx != 0.0 {
ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })]));
}
@@ -633,8 +667,18 @@ impl LineWriter<'_> {
}
/// Writes every wrapped line, returning their underlines.
fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect()
fn write_all(&self, wrapped: &[(String, bool, usize)], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
let mut row = 0usize;
wrapped
.iter()
.enumerate()
.map(|(i, (line, soft, gap))| {
if i > 0 && !soft {
row = row.saturating_add(1);
}
self.write_line(i, line, (row, *soft, *gap), i + 1 == wrapped.len(), cursor, ops)
})
.collect()
}
}
@@ -653,7 +697,7 @@ struct BlockPlan<'a> {
underline: bool,
}
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result<Vec<Op>, EditError> {
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool, usize)], show_font: String) -> Result<Vec<Op>, EditError> {
let n = pdfcraft_content::num;
let mut ops = vec![Op::new("q", vec![])];
ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?);
@@ -747,6 +791,16 @@ pub(crate) fn rewrite_block_from(
) -> Result<LineEdit, EditError> {
let blocks = group_blocks(&lines);
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
// An alignment for a one-row paragraph is laid out across the column it sits in, as a
// paragraph of that width moved to its left edge.
let framed;
let style = match alignment_frame(&lines, &blocks, &b, style, text.unwrap_or(&b.editor_text)) {
Some([left, right]) => {
framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() };
&framed
}
None => style,
};
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
// One clipping run is enough: rewriting the paragraph would drop its mask with the rest.
let mut dropped = coincident_ops(&lines, &members);
@@ -852,10 +906,11 @@ pub(crate) fn rewrite_block_from(
};
let writer = LineWriter {
advance: &advance,
alignment: Alignment { align, width, k, anchored_right, right_edge, rtl },
alignment: Alignment { width, k, anchored_right, right_edge, rtl },
encode: &font.encode,
source: source_metrics,
justify: align == Some(crate::added::Align::Justify),
align,
aligns: &style.aligns,
word_spacing_reaches_spaces,
width,
scale: o_state.scale,
+1 -1
View File
@@ -12,5 +12,5 @@ mod grouping;
#[path = "text_regions/styled.rs"]
mod styled;
pub(super) use grouping::{group_blocks, group_reading_blocks, is_centred};
pub(super) use grouping::{alignment_frame, group_blocks, group_reading_blocks, is_centred};
pub(super) use styled::rewrite_styled_block;
+142 -7
View File
@@ -1,4 +1,4 @@
use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
use super::{BlockStyle, HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union};
fn same_plane(a: &TextLine, b: &TextLine) -> bool {
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
@@ -42,6 +42,54 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option<f64> {
Some(best as f64 / 4.0)
}
/// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of
/// text it sits in (an overlapping paragraph's left edge, to the furthest right its rows reach)
/// when that column holds it, otherwise its own box. Of the columns that hold it, the narrowest is
/// its own, the nearest on a tie: a heading across the whole page, even one closer than the body,
/// is wider than the column the title sits in, and gives it no frame while a narrower one does. A lone row's own box is no frame
/// to align in: left-aligning a title centred on the page left it where it was, and since a single
/// row grows to the page's margin, centring it again moved it right each time. `None` when no
/// alignment is asked for, when the paragraph is several rows, turned, or given a width or a move of
/// its own, or when its `draft` text changes and no column holds it.
pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: &TextBlock, style: &BlockStyle, draft: &str) -> Option<[f64; 2]> {
// Every row of the draft is aligned, by its own alignment or the paragraph's: moving the
// paragraph to its column's edge would move a row that has none. Entries past the draft's
// rows name no row.
let asked = (0..crate::paragraph_align::count(draft)).all(|row| style.aligns.get(row).copied().flatten().or(style.align).is_some());
if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 {
return None;
}
let own = [block.rect[0], block.rect[2]];
let tolerance = block.size.max(1.0) * 0.5;
let gap = |other: &TextBlock| (other.rect[1] - block.rect[3]).max(block.rect[1] - other.rect[3]).max(0.0);
// The column an overlapping paragraph makes, when it holds the row.
let column = |other: &TextBlock| -> Option<[f64; 2]> {
let left = other.rect[0];
// Whole rows, not their runs: a row of several styles reaches its last run's right edge.
let right = other
.rows
.iter()
.filter_map(|row| row_rect(row, lines))
.filter(|row| (row[0] - left).abs() < tolerance)
.map(|row| row[2])
.fold(f64::NEG_INFINITY, f64::max);
(right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance).then_some([left, right])
};
let frame = blocks
.iter()
.filter(|other| other.lines.iter().all(|i| !block.lines.contains(i)) && other.rect[2] > own[0] && other.rect[0] < own[1])
.filter_map(|other| column(other).map(|frame| (frame, gap(other))))
.min_by(|(a, near_a), (b, near_b)| (a[1] - a[0]).total_cmp(&(b[1] - b[0])).then(near_a.total_cmp(near_b)))
.map(|(frame, _)| frame);
// The row's own box, with no column to align in, is no width for new text to wrap at: edited,
// the row keeps the width it would have had without the alignment.
match frame {
Some(frame) => Some(frame),
None if draft != block.editor_text => None,
None => Some(own),
}
}
/// Whether a block reads as centred, and so must keep its centre when it grows or wraps.
///
/// Several ragged rows whose left edges disagree while their centres agree are centred. A one row
@@ -227,14 +275,15 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
}
}
let mut blocks: Vec<TextBlock> = Vec::new();
for row in rows {
for row_at in 0..rows.len() {
let Some(row) = rows.get(row_at) else { continue };
let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue };
let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue };
let (Some(rect), Some(frame)) = (row_rect(row, lines), frame_row_rect(row, lines)) else { continue };
let joins = blocks.last().is_some_and(|block| {
// A bullet of its own opens a list item (#2564): such a row is a new paragraph, and
// letting it join is what made an edit of the row above delete this item and the ones
// after it.
if starts_list_item(&row, lines) {
if starts_list_item(row, lines) {
return false;
}
let Some(prev_row) = block.rows.last() else { return false };
@@ -253,6 +302,36 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|| ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol
|| (pr[2] - frame[2]).abs() < tol
|| hanging;
// Once a paragraph has two rows, its own leading is the measured baseline gap. A
// blank line is about twice that, so a wider gap is the next paragraph (#2517). A
// single row uses the text object's TL when it has one: a gap equal to that TL
// joins, and a gap of twice the TL does not. `Td` does not set TL (`TD` does). A
// step that repeats on the following row is that leading; one large gap, with no
// second step to compare, stays a break. The old cap of 2.5 × the size joined the
// blank line too.
let own_leading = block.rows.len().checked_sub(2).and_then(|i| {
let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n));
let below = prev_row.first().and_then(|n| lines.get(*n));
match (above, below) {
(Some(above), Some(below)) => Some((above.frame_origin().1 - below.frame_origin().1).abs()),
_ => None,
}
});
let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4);
// The next row is not in this block yet. Its gap is only a leading when it matches
// this one: a blank line is a single wider gap and has no matching step after it.
let repeated = rows.get(row_at.saturating_add(1)).and_then(|next| {
let nfirst = next.first().and_then(|i| lines.get(*i))?;
let nframe = frame_row_rect(next, lines)?;
let ngap = first.frame_origin().1 - nfirst.frame_origin().1;
let same = same_plane(first, nfirst)
&& (mixed || same_style(first, nfirst))
&& first.size.min(nfirst.size) >= first.size.max(nfirst.size) * 0.4
&& (nframe[0] - frame[0]).abs() < tol;
let scale = gap.abs().max(ngap.abs());
if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None }
});
let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5);
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
(!mixed || !(invisible(prev) || invisible(first)))
&& (mixed || same_style(prev, first))
@@ -262,12 +341,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
&& aligned
&& first.size.min(prev.size) >= first.size.max(prev.size) * 0.4
&& gap > first.size.min(prev.size) * 0.8
&& gap < first.size.max(prev.size) * 2.5
&& gap < max_gap
});
if !joins {
blocks.push(TextBlock {
text: String::new(),
editor_text: String::new(),
reflow_text: String::new(),
spans: Vec::new(),
rect,
base_font: first.base_font.clone(),
@@ -286,7 +366,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
if !block.rows.is_empty() {
block.editor_text.push('\n');
}
for &index in &row {
for &index in row {
let Some(line) = lines.get(index) else { continue };
// Some producers position separate words without encoding a space.
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
@@ -313,7 +393,62 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
}
block.text.push_str(row_text.trim_matches(paragraph_separator));
block.rect = union(block.rect, rect);
block.rows.push(row);
block.rows.push(row.clone());
}
for block in &mut blocks {
block.reflow_text = reflow_text(block, lines);
}
blocks
}
/// The text a width change should reflow. A `/PCSoft` line is a wrap and rejoins. A `/PCHard`
/// line, or a short line that is not marked, keeps its newline. An unmarked line that reaches
/// the paragraph's right edge and is wide enough to be a measure rejoins too: that is how a
/// file written elsewhere wraps a body line.
fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String {
let parts: Vec<&str> = block.editor_text.split('\n').collect();
if parts.len() != block.rows.len() {
return block.editor_text.clone();
}
let mut out = String::new();
for (index, part) in parts.iter().enumerate() {
if index > 0 {
match break_gap(block, lines, index) {
Some(_) if out.ends_with('-') => {
out.pop();
}
// `gap` is the exact number of separator characters the wrap consumed (0 for a
// Chinese/Japanese word split, which has no space of its own) when the marker or
// the source recorded it; a line already ending in whitespace is left alone so a
// row whose own text kept a trailing space isn't doubled.
Some(gap) if !out.ends_with(char::is_whitespace) => out.push_str(&" ".repeat(gap)),
Some(_) => {}
None => out.push('\n'),
}
}
out.push_str(part);
}
out
}
/// `None`: a hard break, kept as a newline. `Some(gap)`: a soft wrap, rejoined with exactly `gap`
/// spaces — the count a `/PCSoft` marker recorded ([`TextLine::soft_gap`]), or, for a line this
/// app did not mark (a file written elsewhere, or an older save), a single space when the
/// geometry looks like an ordinary wrapped body line (`#2529`).
fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option<usize> {
let line = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i))?;
if line.hard_break {
return None;
}
if line.soft_break {
return Some(line.soft_gap);
}
let prev = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines))?;
let next = block.rows.get(row_index).and_then(|row| row_rect(row, lines))?;
let size = line.size.max(1.0);
let slack = block.rect[2] - prev[2];
let width = prev[2] - prev[0];
// Short rows define the right edge by their own letters. A body line that fills the
// measure leaves almost no room for the next word.
(slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1)
}
+219 -10
View File
@@ -401,13 +401,26 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB
Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0)))
}
type WrappedStyledRows = (StyledRows, RowRanges, Vec<usize>, Vec<bool>, Vec<usize>);
fn wrap_styled_rows(
chars: &[StyledChar],
width: f64,
measure: impl Fn(&[StyledChar]) -> Result<f64, EditError>,
) -> Result<(StyledRows, RowRanges), EditError> {
) -> Result<WrappedStyledRows, EditError> {
let mut rows: Vec<Vec<StyledChar>> = Vec::new();
let mut row_ranges = Vec::new();
// True when this row starts because the previous word did not fit, not because of a newline.
let mut row_soft = Vec::new();
let mut next_soft = false;
// How many separator characters a wrap trimmed off the row that just ended, for the row
// about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless
// the row it belongs to is soft). Mirrors `next_soft`.
let mut row_gap = Vec::new();
let mut next_gap = 0usize;
// Logical draft rows drive per-paragraph alignment independently of soft wraps.
let mut row_lines: Vec<usize> = Vec::new();
let mut draft_line = 0usize;
let mut row_start = 0;
let mut row = Vec::new();
let mut row_width = 0.0;
@@ -419,7 +432,13 @@ fn wrap_styled_rows(
continue;
}
if item.0 == '\n' {
row_soft.push(next_soft);
next_soft = false;
row_gap.push(next_gap);
next_gap = 0;
row_ranges.push(row_start..row_start + row.len());
row_lines.push(draft_line);
draft_line = draft_line.saturating_add(1);
rows.push(std::mem::take(&mut row));
row_width = 0.0;
at += 1;
@@ -439,10 +458,17 @@ fn wrap_styled_rows(
let word = chars.get(at..end).unwrap_or_default();
let word_width = measure(word)?;
if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 {
let mut trimmed = 0usize;
while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) {
row.pop();
trimmed += 1;
}
row_soft.push(next_soft);
next_soft = true;
row_gap.push(next_gap);
next_gap = trimmed;
row_ranges.push(row_start..row_start + row.len());
row_lines.push(draft_line);
rows.push(std::mem::take(&mut row));
row_width = 0.0;
row_start = at;
@@ -452,10 +478,13 @@ fn wrap_styled_rows(
at = end;
}
if !row.is_empty() {
row_soft.push(next_soft);
row_gap.push(next_gap);
row_ranges.push(row_start..row_start + row.len());
row_lines.push(draft_line);
rows.push(row);
}
Ok((rows, row_ranges))
Ok((rows, row_ranges, row_lines, row_soft, row_gap))
}
fn apply_range_styles<'a>(
@@ -609,6 +638,92 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>
.collect()
}
/// Operator index of the paragraph's first text object, and of the next line's text object
/// when one follows.
fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option<usize>)> {
let belongs: std::collections::HashSet<usize> = block.lines.iter().copied().collect();
let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?;
let end =
block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?;
let next = lines
.iter()
.enumerate()
.filter(|(index, _)| !belongs.contains(index))
.filter_map(|(_, line)| (line.origin.bt_op > end).then_some(line.origin.bt_op))
.min();
Some((start, next))
}
/// The scope shared by every line of the paragraph, when each one is in the same marked region.
fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option<usize> {
let mut scopes = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.scope);
let first = scopes.next().flatten()?;
scopes.all(|scope| scope == Some(first)).then_some(first)
}
/// Where an extra `/P` marker belongs.
///
/// The marker is omitted when the paragraph is already one marked region and the next line is
/// in a different one. Otherwise it closes after the paragraph's last `ET` (`ops.len()` means
/// append it after the stream). Closing at the next line's `BT` is too late when that line's
/// region opened with `BDC` before the `BT`: the `EMC` would end the neighbour's region. Closing
/// inside the rewritten operators leaves a kept source row outside the marker.
///
/// A neighbour in the same text object has no `BT` of its own. Closing after the shared `ET`
/// would take that neighbour into the marker. The marker then opens after `BT` and closes
/// before the neighbour's text, so `BT` and `ET` stay matched.
fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option<usize>, usize)> {
let (start, next) = paragraph_span(block, lines)?;
if let Some(region) = shared_scope(block, lines)
&& let Some(at) = next
{
let next_scope = lines.iter().find(|line| line.origin.bt_op == at).map(|line| line.scope);
if next_scope != Some(Some(region)) {
return None;
}
}
let belongs: std::collections::HashSet<usize> = block.lines.iter().copied().collect();
let end =
block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?;
let after_text = ops
.iter()
.enumerate()
.skip(end.saturating_add(1))
.find(|(_, op)| op.op.as_slice() == b"ET")
.map(|(index, _)| index.saturating_add(1))
.unwrap_or(ops.len());
let neighbour = lines
.iter()
.enumerate()
.filter(|(index, _)| !belongs.contains(index))
.filter_map(|(_, line)| line.ops.iter().copied().filter(|op| *op > end).min())
.min();
let open = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied()).min()?;
if let Some(op) = neighbour.filter(|op| *op < after_text) {
// `BT` stays outside the marker. Both ends are inside the one text object.
if open > start {
return Some((open, None, op));
}
}
// The last paragraph of a shared text object has no neighbour after it. Opening at the
// shared `BT` would take the preceding paragraph into the marker. Open at this paragraph's
// first operator instead, and close before the shared `ET`, so both ends stay inside it.
let preceding =
lines.iter().enumerate().filter(|(index, _)| !belongs.contains(index)).any(|(_, line)| line.ops.iter().any(|op| *op > start && *op < open));
if preceding {
if let Some(et) = after_text.checked_sub(1).filter(|index| ops.get(*index).is_some_and(|op| op.op.as_slice() == b"ET"))
&& open > start
&& open < et
{
return Some((open, None, et));
}
return None;
}
// `None` is the next-line index: the close is after this paragraph's `ET`, not at the
// following line's `BT`.
Some((start, None, after_text))
}
struct RewriteAnchor<'a> {
lead: &'a TextLine,
drop: std::collections::HashSet<usize>,
@@ -651,6 +766,78 @@ fn rewrite_anchor<'a>(
RewriteAnchor { lead, drop, start, split }
}
/// Open (`op` is `"BMC"`) or close (`"EMC"`) the `/P` marker that wraps the whole rewrite when
/// `marked` (every row is in this rewrite, see [`rewrite_styled_block`]), so the paragraph
/// cannot be mistaken for a neighbour by geometry alone (#2517).
fn push_whole_paragraph_marker(block_ops: &mut Vec<Op>, marked: bool, op: &str) {
if !marked {
return;
}
let operands = if op == "BMC" { vec![Object::name("P")] } else { Vec::new() };
block_ops.push(Op::new(op, operands));
}
/// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in
/// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of
/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores
/// what was actually there instead of guessing a single space (#2529). Neither is a paragraph
/// region, so the lines stay in one scope.
fn push_row_break_marker(block_ops: &mut Vec<Op>, row_soft: &[bool], row_gap: &[usize], row_index: usize) {
if row_soft.get(row_index).copied().unwrap_or(false) {
let mut properties = Dict::new();
let gap = row_gap.get(row_index).copied().unwrap_or(1);
properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX)));
block_ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)]));
} else {
block_ops.push(Op::new("BMC", vec![Object::name("PCHard")]));
}
block_ops.push(Op::new("EMC", vec![]));
}
/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P`
/// marker opened at its start and closed at its `next` line or, when nothing follows, after the
/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or
/// beyond `ops.len()`, since `splice`'s callback never visits that index).
fn splice_with_marker(
pieces: &Pieces,
ops: &[Op],
marker: Option<(usize, Option<usize>, usize)>,
at: usize,
mut block_ops: Vec<Op>,
drop: &std::collections::HashSet<usize>,
) -> Vec<Vec<u8>> {
let data = splice(pieces, ops, |i| {
let mut insert = Vec::new();
if let Some((start_at, next, close)) = marker {
if i == start_at {
insert.push(Op::new("BMC", vec![Object::name("P")]));
}
if next.is_none() && i == close {
insert.push(Op::new("EMC", vec![]));
}
if next == Some(i) {
insert.push(Op::new("EMC", vec![]));
}
}
if i == at {
insert.append(&mut block_ops);
}
(insert, !drop.contains(&i))
});
if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) {
return data;
}
let mut data = data;
if let Some(last) = data.last_mut() {
if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) {
last.push(b'\n');
}
// `splice` never visits `ops.len()`, so a close after the final operator is appended.
last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])]));
}
data
}
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
pub(crate) fn rewrite_styled_block(
@@ -762,15 +949,21 @@ pub(crate) fn rewrite_styled_block(
}
Ok(width)
};
let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?;
let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
// glyph widths stay exactly as they were instead of being rebuilt from the text state.
let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default();
// glyph widths stay exactly as they were instead of being rebuilt from the text state. A row
// aligned on its own is reached; the rows around it are not.
let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default();
let mut kept = if plain {
untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect()
untouched_rows(block, draft, &row_ranges)
.into_iter()
.zip(&rows)
.zip(&row_lines)
.map(|((same, row), line)| same && row.iter().all(|c| c.1 < source_count) && style.aligns.get(*line).copied().flatten().is_none())
.collect()
} else {
vec![false; rows.len()]
};
@@ -796,6 +989,12 @@ pub(crate) fn rewrite_styled_block(
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
let (tx, ty) = back.apply(dx, dy);
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
// A new row can land one leading above the next paragraph and look like the same
// paragraph. When every row is in this rewrite, a paragraph marker keeps them in one
// scope so the neighbour stays out (#2517). A row left in the source stream cannot share
// that marker, so it is omitted and geometry groups those rows as before.
let marked = kept.iter().all(|row| !*row);
push_whole_paragraph_marker(&mut block_ops, marked, "BMC");
let crop = p.crop(doc);
let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size);
let source_leading = block
@@ -823,11 +1022,16 @@ pub(crate) fn rewrite_styled_block(
baseline -= source_leading;
continue;
}
if !row.is_empty() && row_index > 0 {
push_row_break_marker(&mut block_ops, &row_soft, &row_gap, row_index);
}
let w = measured.get(row_index).copied().unwrap_or(0.0);
let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
// The row's own alignment, or the paragraph's.
let asked = row_lines.get(row_index).and_then(|l| style.aligns.get(*l)).copied().flatten().or(style.align);
let align = asked.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] };
let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width };
let left = if style.width.is_none() && asked.is_none() { original_rect[0] } else { block.rect[0] };
let available = if style.width.is_none() && asked.is_none() { original_rect[2] - original_rect[0] } else { width };
let mut x = left
+ match align {
crate::added::Align::Center => (available - w) / 2.0,
@@ -966,6 +1170,7 @@ pub(crate) fn rewrite_styled_block(
style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
};
}
push_whole_paragraph_marker(&mut block_ops, marked, "EMC");
block_ops.push(Op::new("Q", vec![]));
block_ops.extend(after.ops());
if split {
@@ -974,7 +1179,11 @@ pub(crate) fn rewrite_styled_block(
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
}
let at = if split { start } else { o.bt_op };
let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i)));
// A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is
// safe. The close is not part of `block_ops`: that vector can end inside a text object
// when the rewrite splits one, and a kept row's operators come later in the stream.
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop);
let contents = rewritten_contents(doc, &streams, data);
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| {
+1
View File
@@ -82,6 +82,7 @@ pub use pdfcraft_edit::added::{
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
text_width as added_text_width,
};
pub use pdfcraft_edit::paragraph_align;
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
+3
View File
@@ -3936,6 +3936,7 @@ fn pasted_added_content_keeps_its_style_and_picture() {
size: 12.0,
color: [0.8, 0.1, 0.2],
align: TextAlign::Center,
aligns: Vec::new(),
char_spacing: 0.0,
};
s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap();
@@ -7855,6 +7856,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() {
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
aligns: Vec::new(),
char_spacing: 0.0,
};
session.apply(id, Edit::AddText { page: 1, text }).unwrap();
@@ -7895,6 +7897,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
aligns: Vec::new(),
char_spacing: 0.0,
},
},
+2
View File
@@ -18,5 +18,7 @@ flate2.workspace = true
thiserror.workspace = true
[dev-dependencies]
hayro-ccitt.workspace = true
hayro-jbig2.workspace = true
proptest.workspace = true
weezl.workspace = true
+13 -1
View File
@@ -3,7 +3,9 @@
PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`.
- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3).
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here.
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte.
Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution.
- `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`.
## API
@@ -25,6 +27,9 @@ encode_flate(data) -> Vec<u8> // zlib, level 6
encode_g4(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS
pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults
decode_ccitt(data, &params, max_rows) -> Result<Vec<u8>, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS
encode_jbig2_generic(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS
encode_jbig2(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS
encode_jbig2_generic(data, columns, rows) -> Result<Vec<u8>, FilterError> // the same as one generic region
```
- `chain` is applied first to last, as in a `/Filter` array.
@@ -44,6 +49,8 @@ decode_ccitt(data, &params, max_rows) -> Result<Vec<u8>, FilterError> // CCITT
| ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` |
| RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 |
| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt |
| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt |
| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made |
## Tests
@@ -51,8 +58,13 @@ decode_ccitt(data, &params, max_rows) -> Result<Vec<u8>, FilterError> // CCITT
- LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings.
- `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams).
- `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics.
- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions.
- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics.
- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics.
- `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`).
## Status
M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, DCT encode), CCITT Group 3 encoding and Flate encode levels other than 6.
Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution.
+368
View File
@@ -0,0 +1,368 @@
//! JBIG2 encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` (ISO 32000-2 §7.4.7), lossless:
//! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ
//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from
//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8
//! (the segments) and D.3 (the embedded organisation PDF uses).
mod symbols;
use crate::FilterError;
use crate::mq::{Context, MqEncoder};
/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a
/// row's buffers small and its positions in range.
pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20;
/// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the
/// context in this order: the four on the current row, the five fixed ones on the row above, the
/// three on the row above that, then the adaptive pixels A1–A4 at their nominal places (Table 5).
const TEMPLATE: [(i32, i32); 16] = [
(-1, 0),
(-2, 0),
(-3, 0),
(-4, 0),
(-2, -1),
(-1, -1),
(0, -1),
(1, -1),
(2, -1),
(-1, -2),
(0, -2),
(1, -2),
(3, -1),
(-3, -1),
(2, -2),
(-2, -2),
];
/// The context that codes SLTP when template 0 is used (Figure 8): those pixel values, in
/// [`TEMPLATE`]'s order. Figure 8 reads, by row from x = -4: `0 1 0 1` on the current row,
/// `0 1 1 0 0 1 0` (x = -3 to 3) above it, `1 0 0 1 1` (x = -2 to 2) above that.
const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1];
/// Columns of padding either side of a row, so every template pixel of a row's pixels is in it.
const PAD: usize = 4;
/// `rows` rows of `columns` 1-bit PDF samples as lossless JBIG2, the smaller of two codings:
/// [`encode_jbig2_generic`]'s, or one that stores each distinct mark of the image once, as a
/// symbol, and places its copies (see [`symbols`]). Either way the stream is in the embedded
/// organisation and decodes to the same bits without `/JBIG2Globals`.
///
/// Errors: as [`encode_jbig2_generic`].
pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result<Vec<u8>, FilterError> {
let generic = encode_jbig2_generic(data, columns, rows)?;
// `encode_jbig2_generic` checked that the rows are there.
let stride = (columns as usize).div_ceil(8);
let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n));
Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) {
Some(coded) if coded.len() < generic.len() => coded,
_ => generic,
})
}
/// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on
/// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an
/// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits
/// without `/JBIG2Globals`.
///
/// A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, as a grey image's
/// samples have it. Bytes after the last row are ignored, as are the bits that pad a row to a whole
/// byte.
///
/// Errors: no rows or columns, or rows wider than [`MAX_JBIG2_COLUMNS`]
/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]).
pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result<Vec<u8>, FilterError> {
if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS {
return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples")));
}
let width = columns as usize;
let stride = width.div_ceil(8);
let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt {
filter: "JBIG2Decode",
detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()),
})?;
let mut contexts: Vec<Context> = vec![0; 1 << 16];
let mut mq = MqEncoder::new();
let height = samples.len() / stride;
code_generic(&mut mq, &mut contexts, width, height, true, |y, row| {
let packed = samples.get(y * stride..).unwrap_or_default();
for (x, pixel) in row.iter_mut().enumerate() {
let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0);
*pixel = u8::from(!sample);
}
});
let region = mq.finish();
let mut out = Vec::with_capacity(region.len() + 64);
segment(&mut out, 0, 48, &[], false, &page_information(columns, rows));
segment(&mut out, 1, 39, &[], false, &generic_region(columns, rows, 0, 0, &region));
Ok(out)
}
/// A page information segment's data (7.4.8): the page's size, resolution unknown, eventually
/// lossless, default pixel 0 (white), OR combination, not striped.
fn page_information(columns: u32, rows: u32) -> Vec<u8> {
let mut page = Vec::with_capacity(19);
page.extend_from_slice(&columns.to_be_bytes());
page.extend_from_slice(&rows.to_be_bytes());
page.extend_from_slice(&[0; 8]);
page.push(0x01);
page.extend_from_slice(&[0, 0]);
page
}
/// A region segment information field (7.4.1): a `width` × `height` region at (`x`, `y`) on the
/// page, combined with it by OR.
fn region_information(out: &mut Vec<u8>, width: u32, height: u32, x: u32, y: u32) {
for v in [width, height, x, y] {
out.extend_from_slice(&v.to_be_bytes());
}
out.push(0);
}
/// An immediate lossless generic region segment's data (7.4.6): its region information, flags
/// (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then `coded`.
fn generic_region(width: u32, height: u32, x: u32, y: u32, coded: &[u8]) -> Vec<u8> {
let mut data = Vec::with_capacity(26 + coded.len());
region_information(&mut data, width, height, x, y);
data.push(0x08);
at_pixels(&mut data);
data.extend_from_slice(coded);
data
}
/// Template 0's four AT pixels at their nominal places, as AT flags (7.4.6.3, 7.4.2.1.2).
fn at_pixels(out: &mut Vec<u8>) {
for (x, y) in &TEMPLATE[12..] {
out.push(*x as i8 as u8);
out.push(*y as i8 as u8);
}
}
/// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length),
/// then its data. `retained`: a later segment refers to this one. `refers_to` holds each
/// referred-to segment's number and whether a segment after this one refers to it too (its
/// retain bit, 7.2.4). Segment numbers here stay below 256, so each referred-to one takes a byte
/// (7.2.5); a segment refers to at most four, so their count and retain bits take one byte too.
fn segment(out: &mut Vec<u8>, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) {
out.extend_from_slice(&number.to_be_bytes());
out.push(kind);
// At most four, so the count fits its three bits.
let refers_to = refers_to.get(..refers_to.len().min(4)).unwrap_or_default();
let kept = refers_to.iter().enumerate().fold(0u8, |bits, (k, (_, kept))| bits | u8::from(*kept) << (k + 1));
out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained));
out.extend(refers_to.iter().map(|(n, _)| *n));
out.push(1);
// A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide.
out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(data);
}
/// Codes a `width` × `height` bitmap as a generic region (6.2.5.7) with template 0 and the nominal
/// AT pixels, its decisions in `contexts` (2^16 of them), with typical prediction when `typical`
/// (TPGDON). `fill(y, row)` sets row `y`'s `width` pixels, 1 for black. Pixels outside the bitmap
/// count as 0 (white), as the decoder takes them.
fn code_generic(mq: &mut MqEncoder, contexts: &mut [Context], width: usize, height: usize, typical: bool, mut fill: impl FnMut(usize, &mut [u8])) {
let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k);
// The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded.
let blank = vec![0u8; width + 2 * PAD];
let (mut above2, mut above1) = (blank.clone(), blank.clone());
let mut row = blank;
let mut ltp = false;
for y in 0..height {
if let Some(pixels) = row.get_mut(PAD..PAD + width) {
fill(y, pixels);
}
if typical {
// Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision.
let same = row == above1;
if let Some(cx) = contexts.get_mut(sltp) {
mq.encode(cx, same != ltp);
}
ltp = same;
}
if !ltp {
for x in 0..width {
let at = |dx: i32, dy: i32| {
let line = match dy {
0 => &row,
-1 => &above1,
_ => &above2,
};
let i = (PAD + x).checked_add_signed(dx as isize);
i.and_then(|i| line.get(i)).copied().unwrap_or(0)
};
let cx = TEMPLATE.iter().enumerate().fold(0usize, |cx, (k, (dx, dy))| cx | usize::from(at(*dx, *dy)) << k);
if let Some(state) = contexts.get_mut(cx) {
mq.encode(state, row.get(PAD + x) == Some(&1));
}
}
}
std::mem::swap(&mut above2, &mut above1);
std::mem::swap(&mut above1, &mut row);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true
/// for white (a JBIG2 0).
pub(super) fn decode(data: &[u8]) -> Vec<Vec<bool>> {
struct Rows(Vec<Vec<bool>>, Vec<bool>, usize);
impl hayro_jbig2::Decoder for Rows {
fn push_pixel(&mut self, black: bool) {
self.1.push(!black);
}
fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
self.1.extend(std::iter::repeat_n(!black, chunks as usize * 8));
}
fn next_line(&mut self) {
let mut row = std::mem::take(&mut self.1);
row.truncate(self.2);
self.0.push(row);
}
}
let image = hayro_jbig2::Image::new_embedded(data, None).unwrap();
let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize);
image.decode(&mut rows).unwrap();
rows.0
}
/// The rows of packed samples `data`, true for a 1 bit.
pub(super) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec<Vec<bool>> {
let stride = (columns as usize).div_ceil(8);
(0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect()
}
/// Rows of `columns` samples from `runs`: alternating white and black runs one longer than
/// those values (never empty, even when shrinking makes them all 0), until the rows are full;
/// or, with `noise`, the bytes of `runs` as they are.
fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec<u8> {
let stride = (columns as usize).div_ceil(8);
let mut data = vec![0u8; stride * rows as usize];
if runs.is_empty() {
return data;
}
if noise {
data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r);
return data;
}
let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1);
for y in 0..rows as usize {
for x in 0..columns as usize {
if left == 0 {
white = !white;
left = 1 + usize::from(runs[next % runs.len()]);
next += 1;
}
if white {
data[y * stride + x / 8] |= 0x80 >> (x % 8);
}
left -= 1;
}
}
data
}
#[test]
fn a_page_of_text_like_marks_round_trips_and_shrinks() {
let (w, h) = (1700u32, 220u32);
let stride = (w as usize).div_ceil(8);
let mut data = vec![0xFF; stride * h as usize];
for y in (20..h as usize).step_by(40) {
for dy in 0..12 {
for x in (100..1600).step_by(9) {
if (x / 9 + dy) % 5 != 0 {
data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8));
}
}
}
}
let jbig2 = encode_jbig2_generic(&data, w, h).unwrap();
assert_eq!(decode(&jbig2), unpack(&data, w, h));
let g4 = crate::encode_g4(&data, w, h).unwrap();
assert!(jbig2.len() < g4.len(), "JBIG2 {} bytes, Group 4 {}", jbig2.len(), g4.len());
}
#[test]
fn edges_padding_and_typical_rows_round_trip() {
// 3 columns: the last 5 bits of each byte pad the row; set them to anything.
let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD];
assert_eq!(decode(&encode_jbig2_generic(&data, 3, 3).unwrap()), [[true, false, true], [false, true, false], [true, true, true]]);
for (w, h, fill) in [(1, 1, 0x00), (1, 1, 0xFF), (13, 9, 0x00), (13, 9, 0xFF), (64, 40, 0x5A)] {
let data = vec![fill; (w as usize).div_ceil(8) * h as usize];
assert_eq!(decode(&encode_jbig2_generic(&data, w, h).unwrap()), unpack(&data, w, h), "{w} × {h} of {fill:#04x}");
}
}
/// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1.
pub(super) fn samples(pixels: &[Vec<u8>]) -> Vec<u8> {
let width = pixels.first().map_or(0, Vec::len);
let stride = width.div_ceil(8);
let mut data = vec![0xFF; stride * pixels.len()];
for (y, row) in pixels.iter().enumerate() {
for (x, p) in row.iter().enumerate() {
if *p == 1 {
data[y * stride + x / 8] &= !(0x80 >> (x % 8));
}
}
}
data
}
/// A row the same as the one above is typical (6.2.5.5): one decision, not a row of pixels.
#[test]
fn a_repeated_row_is_one_decision() {
let row: Vec<u8> = (0..800u32).map(|x| u8::from((x * 7919 + x / 3) % 5 < 2)).collect();
let pixels = vec![row; 1000];
let data = samples(&pixels);
crate::mq::DECISIONS.with(|n| n.set(0));
let jbig2 = encode_jbig2_generic(&data, 800, 1000).unwrap();
// The first row's 800 pixels, and one typical-row decision for each of the 1000 rows.
assert_eq!(crate::mq::DECISIONS.with(|n| n.get()), 800 + 1000);
assert_eq!(decode(&jbig2), unpack(&data, 800, 1000));
}
/// SLTP is coded in the context Figure 8 shows, which ordinary pixels can share: an image
/// tiled with that very neighbourhood decodes only if both coders use the same context.
#[test]
fn the_typical_row_flag_shares_figure_8s_context() {
// Figure 8, around a pixel at x = 4 of an 8-column tile: two rows up x = 2..6, one row up
// x = 1..7, the pixel's own row x = 0..3.
let tile = [[0, 0, 1, 0, 0, 1, 1, 0], [0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 0, 1, 1, 0, 0, 0]];
let pixels: Vec<Vec<u8>> = (0..90).map(|y| (0..400).map(|x| tile[y % 3][x % 8]).collect()).collect();
let data = samples(&pixels);
assert_eq!(decode(&encode_jbig2_generic(&data, 400, 90).unwrap()), unpack(&data, 400, 90));
}
#[test]
fn what_it_cannot_encode_is_an_error() {
assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_))));
assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_))));
assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_))));
assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes");
assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. })));
}
proptest::proptest! {
/// Any bitmap, of noise or of runs, decodes to its own bits through an independent decoder.
#[test]
fn bitmaps_round_trip(
columns in 1u32..300,
rows in 1u32..24,
runs in proptest::collection::vec(0u8..=255, 1..48),
noise in proptest::bool::ANY,
) {
let data = bitmap(columns, rows, &runs, noise);
let jbig2 = encode_jbig2_generic(&data, columns, rows).unwrap();
proptest::prop_assert_eq!(decode(&jbig2), unpack(&data, columns, rows));
}
/// Whatever it is given, it returns.
#[test]
fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) {
let _ = encode_jbig2_generic(&data, columns, rows);
}
}
}
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -7,7 +7,7 @@
//! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`),
//! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so
//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4
//! ([`encode_g4`]).
//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]).
//!
//! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb
//! defence) and never panics on malformed input.
@@ -18,13 +18,16 @@ mod ascii85;
mod asciihex;
mod ccitt;
mod flate;
mod jbig2;
mod lzw;
mod mq;
mod predictor;
mod runlength;
use std::borrow::Cow;
pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4};
pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic};
/// A PDF stream filter, identified by its `/Filter` name.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+209
View File
@@ -0,0 +1,209 @@
//! The MQ arithmetic encoder of JBIG2 (ITU-T T.88 | ISO/IEC 14492, Annex E), written from the
//! procedures in its Figures E.2–E.12 and the probability estimates of Table E.1.
/// Table E.1: for each index, the LPS probability estimate Qe, the next index after an MPS and
/// after an LPS renormalisation, and whether an LPS switches the MPS sense.
const STATES: [(u32, u8, u8, bool); 47] = [
(0x5601, 1, 1, true),
(0x3401, 2, 6, false),
(0x1801, 3, 9, false),
(0x0AC1, 4, 12, false),
(0x0521, 5, 29, false),
(0x0221, 38, 33, false),
(0x5601, 7, 6, true),
(0x5401, 8, 14, false),
(0x4801, 9, 14, false),
(0x3801, 10, 14, false),
(0x3001, 11, 17, false),
(0x2401, 12, 18, false),
(0x1C01, 13, 20, false),
(0x1601, 29, 21, false),
(0x5601, 15, 14, true),
(0x5401, 16, 14, false),
(0x5101, 17, 15, false),
(0x4801, 18, 16, false),
(0x3801, 19, 17, false),
(0x3401, 20, 18, false),
(0x3001, 21, 19, false),
(0x2801, 22, 19, false),
(0x2401, 23, 20, false),
(0x2201, 24, 21, false),
(0x1C01, 25, 22, false),
(0x1801, 26, 23, false),
(0x1601, 27, 24, false),
(0x1401, 28, 25, false),
(0x1201, 29, 26, false),
(0x1101, 30, 27, false),
(0x0AC1, 31, 28, false),
(0x09C1, 32, 29, false),
(0x08A1, 33, 30, false),
(0x0521, 34, 31, false),
(0x0441, 35, 32, false),
(0x02A1, 36, 33, false),
(0x0221, 37, 34, false),
(0x0141, 38, 35, false),
(0x0111, 39, 36, false),
(0x0085, 40, 37, false),
(0x0049, 41, 38, false),
(0x0025, 42, 39, false),
(0x0015, 43, 40, false),
(0x0009, 44, 41, false),
(0x0005, 45, 42, false),
(0x0001, 45, 43, false),
(0x5601, 46, 46, false),
];
#[cfg(test)]
thread_local! {
/// Decisions coded on this thread.
pub(crate) static DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A context's adaptive state: the index into [`STATES`] (low 7 bits) and the MPS sense (bit 7).
/// 0, the initial value, is index 0 with MPS 0 (E.2.8).
pub(crate) type Context = u8;
/// The encoder's registers (E.2.1) and the bytes written so far.
pub(crate) struct MqEncoder {
a: u32,
c: u32,
ct: u32,
/// The output, after the byte before the first one (B at INITENC), which is 0x00 and is
/// dropped by [`MqEncoder::finish`]: a carry can't reach it before the first byte is out.
out: Vec<u8>,
}
impl MqEncoder {
/// INITENC (Figure E.10). The byte before the first is 0x00, not 0xFF, so CT starts at 12.
pub(crate) fn new() -> Self {
MqEncoder { a: 0x8000, c: 0, ct: 12, out: vec![0] }
}
/// The byte B, the last one written (or the one before the first).
fn b(&self) -> u8 {
self.out.last().copied().unwrap_or(0)
}
/// ENCODE (Figures E.3–E.7): code decision `d` in context `cx`, updating its state.
pub(crate) fn encode(&mut self, cx: &mut Context, d: bool) {
#[cfg(test)]
DECISIONS.with(|n| n.set(n.get() + 1));
let (index, mps) = (usize::from(*cx & 0x7F), *cx >> 7 == 1);
let Some(&(qe, nmps, nlps, switch)) = STATES.get(index) else { return };
self.a -= qe;
if d == mps {
// CODEMPS.
if self.a & 0x8000 != 0 {
self.c += qe;
return;
}
if self.a < qe {
self.a = qe;
} else {
self.c += qe;
}
*cx = nmps | (*cx & 0x80);
} else {
// CODELPS.
if self.a < qe {
self.c += qe;
} else {
self.a = qe;
}
let mps = mps != switch;
*cx = nlps | (u8::from(mps) << 7);
}
self.renorm();
}
/// RENORME (Figure E.8).
fn renorm(&mut self) {
loop {
self.a <<= 1;
self.c <<= 1;
self.ct -= 1;
if self.ct == 0 {
self.byte_out();
}
if self.a & 0x8000 != 0 {
break;
}
}
}
/// BYTEOUT (Figure E.9), with bit stuffing after 0xFF and carry into the byte before.
fn byte_out(&mut self) {
if self.b() == 0xFF {
self.stuffed();
} else if self.c < 0x800_0000 {
self.plain();
} else {
let b = self.b().wrapping_add(1);
if let Some(last) = self.out.last_mut() {
*last = b;
}
if b == 0xFF {
self.c &= 0x7FF_FFFF;
self.stuffed();
} else {
self.plain();
}
}
}
fn plain(&mut self) {
self.out.push(((self.c >> 19) & 0xFF) as u8);
self.c &= 0x7_FFFF;
self.ct = 8;
}
fn stuffed(&mut self) {
self.out.push(((self.c >> 20) & 0xFF) as u8);
self.c &= 0xF_FFFF;
self.ct = 7;
}
/// FLUSH (Figures E.11 and E.12): the final bytes, then the 0xFF 0xAC marker.
pub(crate) fn finish(mut self) -> Vec<u8> {
let top = self.c + self.a;
self.c |= 0xFFFF;
if self.c >= top {
self.c -= 0x8000;
}
self.c <<= self.ct;
self.byte_out();
self.c <<= self.ct;
self.byte_out();
if self.b() != 0xFF {
self.out.push(0xFF);
}
self.out.push(0xAC);
self.out.split_off(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Annex H.2's test sequence: 256 decisions in one context give these 30 bytes.
#[test]
fn the_annex_h_test_sequence_encodes_to_its_bytes() {
let decisions = [
0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E,
0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF,
];
let mut mq = MqEncoder::new();
let mut cx = 0;
for byte in decisions {
for bit in (0..8).rev() {
mq.encode(&mut cx, byte >> bit & 1 == 1);
}
}
let expected = [
0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37,
0x47, 0x1A, 0xDB, 0x6A, 0xDF, 0xFF, 0xAC,
];
assert_eq!(mq.finish(), expected);
}
}
+11 -2
View File
@@ -87,10 +87,15 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
left alone. `calculations_changed` compares two readings of a form to say which formulas an
edit rewrote.
- The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the
order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`,
order `recalculate` runs them, each with its field object (two can share a name in a malformed
form), with the fields each formula reads (`Calculate::reads`,
`af::calculation_reads`; names built at run time aren't known) and what is wrong with the
order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO`
entries that calculate nothing. `CalculationOrder::fixed` is Fix order: a stable topological
entries that calculate nothing. Two issues are about the formula itself: it reads a field the
form doesn't have (`MissingField`; the field of that name and a field under it count as there,
as recalculation and `getField` find them, but not an Acrobat widget name such as `price.0`,
which neither resolves), or its script builds names at run time (`BuiltNames`,
`af::reads_built_names`), so its reads are incomplete. `CalculationOrder::fixed` is Fix order: a stable topological
sort that keeps the current order wherever it can and leaves loops as they are.
`set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of
the order; one whose calculation changes keeps its place.
@@ -108,3 +113,7 @@ Rich-text values and exact appearance metrics/layout remain incomplete. JavaScri
execution and FDF/XFDF exchange are integrated through the engine; this crate provides
field actions and native AF rules. `/MK /R` rotation (0, 90, 180, 270) is drawn into the
appearance.
## Button export values
`FieldProps::export_value` edits a zero-based widget's `/Opt` value while preserving appearance names, `/AP`, `/AS`, `/V` and `/DV`. Widgets sharing an on-state remain consistent. Export-only changes do not regenerate appearances. Invalid indices, empty/Off values and ambiguous/conflicting mappings are rejected. `Field::export_values` resolves selected states for data export.
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 81dc3893ad0b46189d4e80199f44f8e58fcf0d193f67e266e37f3846f756a11e # shrinks to names = [" "]
+2 -2
View File
@@ -19,10 +19,10 @@ mod script;
#[cfg(test)]
use calc::{Notation, eval_notation, notation_name, notation_tokens, unique};
pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation};
pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation};
pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate};
use script::{Arg, call};
pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js};
pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js};
/// The Format tab.
#[derive(Clone, Debug, PartialEq, Default)]
+62 -20
View File
@@ -206,6 +206,10 @@ enum Site {
Commas(String),
/// A name in the simplified field notation of a `BVCALC` comment.
Notation(String),
/// A field read by a name built at run time: a `getField` whose argument is more than one
/// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't
/// written out. Which field it is isn't known.
Built,
}
impl Site {
@@ -213,6 +217,7 @@ impl Site {
match self {
Site::Literal(name) | Site::Notation(name) => vec![name.as_str()],
Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(),
Site::Built => Vec::new(),
}
}
@@ -229,14 +234,16 @@ impl Site {
js_str(&parts.join(","))
})
}
Site::Built => None,
}
}
}
/// Where a Calculate script names fields, in order: the simplified field notation in its comment
/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
/// A name built at run time (`getField("row" + i)`) is not a site.
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name
/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or
/// `AFSimple_Calculate`.
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
let mut sites = Vec::new();
let (tokens, comments) = js_tokens(js);
@@ -255,35 +262,64 @@ fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
Some((range, Js::Str(text))) => Some((range.clone(), text.clone())),
_ => None,
};
let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built));
for k in 0..tokens.len() {
if ident(k, "getField")
&& punct(k + 1, '(')
&& punct(k + 3, ')')
&& let Some((range, name)) = string(k + 2)
{
sites.push((range, Site::Literal(name)));
if ident(k, "getField") && punct(k + 1, '(') {
match string(k + 2) {
Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))),
_ => sites.extend(built(k)),
}
}
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) {
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) {
continue;
}
if !(string(k + 2).is_some() && punct(k + 3, ',')) {
sites.extend(built(k));
continue;
}
// One string of comma-separated names, when it is the whole list (not `"row" + suffix`).
// An argument after it leaves the list as written, as after an array: its names are read,
// and what follows is a name built at run time.
if let Some((range, list)) = string(k + 4) {
sites.push((range, Site::Commas(list)));
if punct(k + 5, ')') || punct(k + 5, ',') {
sites.push((range, Site::Commas(list)));
}
if !punct(k + 5, ')') {
sites.extend(built(k));
}
continue;
}
// `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string.
// Nothing else is read, so no token is read for two calls.
let first = if punct(k + 4, '[') {
k + 5
// `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has
// to end the call. An item that is more than a string (`"row" + i`), or anything after
// the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no
// token is read for two calls.
let (first, close) = if punct(k + 4, '[') {
(k + 5, ']')
} else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
k + 7
(k + 7, ')')
} else {
sites.extend(built(k));
continue;
};
for (range, token) in tokens.iter().skip(first) {
match token {
Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
Js::Punct(',') => {}
_ => break,
let mut j = first;
loop {
match tokens.get(j) {
// An item ends at a comma or the list's end, or where a script cut short ends.
Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => {
sites.push((range.clone(), Site::Literal(name.clone())));
j += 1;
}
Some((_, Js::Punct(','))) => j += 1,
Some((_, Js::Punct(c))) if *c == close => {
if !punct(j + 1, ')') {
sites.extend(built(k));
}
break;
}
_ => {
sites.extend(built(k));
break;
}
}
}
}
@@ -315,6 +351,12 @@ pub fn calculation_reads(js: &str) -> Vec<String> {
unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned))
}
/// Whether a Calculate script also reads fields by names it builds at run time
/// (`getField("row" + i)`), which [`calculation_reads`] can't list.
pub fn reads_built_names(js: &str) -> bool {
calculation_sites(js).iter().any(|(_, site)| *site == Site::Built)
}
impl Calculate {
/// The fields it reads, each once, in the order it first names them. A name also stands for
/// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them.
+25 -2
View File
@@ -1,4 +1,4 @@
use super::calc::{js_tokens, notation_js, notation_range};
use super::calc::{js_tokens, notation_js, notation_range, notation_tokens};
use super::format::{DATE_PRESETS, TIME_PRESETS};
use super::{CalcOp, Calculate, Format, Validate};
@@ -255,7 +255,7 @@ pub fn parse_calculate(js: &str) -> Option<Calculate> {
if js.contains("BVCALC")
&& let Some(range) = notation_range(js, &js_tokens(js).1)
{
return Some(Calculate::Notation(js.get(range)?.trim().to_string()));
return Some(Calculate::Notation(notation_text(js.get(range)?).to_string()));
}
let args = call(js, "AFSimple_Calculate")?;
let op = match args.first().and_then(Arg::string)?.as_str() {
@@ -325,6 +325,29 @@ pub fn validate_js(v: &Validate) -> Option<String> {
}
}
/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it.
pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\";
/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment,
/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and
/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the
/// space after it. An empty one is refused too: it would be stored as `event.value = ;`.
pub fn notation_storable(expr: &str) -> bool {
let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::<Vec<_>>();
let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js));
!tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr))
}
/// `src` from its first notation token to its last: the spaces around it left out, but not an
/// escaped space that ends the last name (`Total\ `), as `str::trim` would.
fn notation_text(src: &str) -> &str {
let tokens = notation_tokens(src);
match (tokens.first(), tokens.last()) {
(Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(),
_ => "",
}
}
pub fn calculate_js(c: &Calculate) -> Option<String> {
match c {
Calculate::None => None,
+76
View File
@@ -156,6 +156,57 @@ fn a_regex_after_a_condition_is_not_read_as_code() {
assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\"")));
}
/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that
/// isn't written out, reads a field whose name isn't known until the script runs.
#[test]
fn names_built_at_run_time_are_noticed() {
for js in [
"event.value = getField(\"row\" + i).value;",
"event.value = this.getField(name).value;",
"AFSimple_Calculate(\"SUM\", list);",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", b));",
"AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));",
"AFSimple_Calculate(op, new Array(\"a\"));",
"AFSimple_Calculate(\"SUM\", \"row\" + suffix);",
"AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));",
"AFSimple_Calculate(\"SUM\", [\"row\" + i]);",
] {
assert!(reads_built_names(js), "{js}");
}
// A string that is only the start of a name or a list isn't read, or renamed, as one.
for (js, reads) in [
("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]),
("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]),
] {
assert_eq!(parse_calculate(js), None, "{js} is a script");
assert_eq!(calculation_reads(js), reads, "{js}");
assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}");
}
for js in [
"event.value = getField(\"a\").value + getField('b').value;",
"AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));",
"AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);",
"AFSimple_Calculate(\"SUM\", \"a, b\");",
"// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;",
"/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;",
] {
assert!(!reads_built_names(js), "{js}");
}
// The names it does know are still read and renamed.
let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));";
assert_eq!(calculation_reads(js), ["a"]);
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\"")));
// So are a list's, string or array, with another argument after it.
for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] {
assert!(reads_built_names(js), "{js}");
assert_eq!(calculation_reads(js), ["a", "b"], "{js}");
assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}");
}
}
/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in
/// one another's field lists, don't make a rename read the rest of the script again for each.
#[test]
@@ -166,6 +217,21 @@ fn hostile_scripts_are_read_in_one_pass() {
assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000))));
}
/// A notation is stored only when it reads back from the script's comment as itself.
#[test]
fn a_notation_is_stored_only_when_it_reads_back() {
assert!(notation_storable("Price * Qty"));
assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read");
// A last name that ends in a space keeps it, escaped, when the notation is read back.
let total = "Qty * Total\\ ";
assert!(notation_storable(total));
let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap());
assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()]));
for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] {
assert!(!notation_storable(expr), "{expr:?}");
}
}
/// Notation is read through one lexer: field names with escapes, numbers and operators.
#[test]
fn notation_is_read_by_one_lexer() {
@@ -213,6 +279,16 @@ proptest::proptest! {
proptest::prop_assert_eq!(back, Some(js));
}
/// A notation of any names, written into a script, reads back from it as written.
#[test]
fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) {
proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC")));
let expr = names.iter().map(|n| notation_name(n)).collect::<Vec<_>>().join(" + ");
let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap();
proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone())));
proptest::prop_assert!(notation_storable(&expr), "{expr:?}");
}
/// What a formula reads is the same whether it is read from the tab's settings or from
/// the script they are written as.
#[test]
+3
View File
@@ -88,6 +88,9 @@ pub struct FieldProps {
pub max_len: Option<Option<usize>>,
/// Choice options (display texts; export values equal them).
pub options: Option<Vec<String>>,
/// Change one button widget's export value without changing its appearance state.
/// Widgets sharing that state are kept in sync. The index is zero-based.
pub export_value: Option<(usize, String)>,
/// Font size (0 = auto).
pub font_size: Option<f64>,
/// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in
+48
View File
@@ -77,6 +77,35 @@ fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) ->
(sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried)
}
/// Build /Opt before modifying the document, preserving appearance states and selection.
fn button_export_options(f: &Field, index: usize, value: &str) -> Result<Vec<Object>, FormError> {
if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
return invalid("only check boxes and radio buttons have an export value");
}
if value.is_empty() || value == "Off" || value.len() > 4096 {
return invalid("export value must be non-empty, at most 4096 bytes, and not Off");
}
let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?;
let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
if !f.options.is_empty() && f.options.len() != f.widgets.len() {
return invalid("button export options do not match its widgets");
}
let previous = f.export_of(index);
let mut out = Vec::with_capacity(f.widgets.len());
for (i, w) in f.widgets.iter().enumerate() {
let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?;
let same_state = w.on_state.as_deref() == Some(state);
if !same_state && (export == value || w.on_state.as_deref() == Some(value)) {
return invalid("export value conflicts with another button choice");
}
if same_state && Some(export) != previous {
return invalid("widgets with the same appearance state have conflicting export values");
}
out.push(Object::String(PdfString::text(if same_state { value } else { export })));
}
Ok(out)
}
/// Change a field's properties (General and Options tabs) and redraw it.
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
@@ -88,8 +117,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
patch.validate()?;
}
// Refused here, before anything is written, rather than stored as a different formula.
if let Some(crate::af::Calculate::Notation(n)) = &props.calculate
&& !crate::af::notation_storable(n)
{
return invalid(crate::af::NOTATION_NOT_STORABLE);
}
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?;
if props.export_value.is_some() && props.options.is_some() {
return invalid("give export_value or options, not both");
}
// Refuse a wrong type/index before any other requested property is written.
if let Some((wi, _)) = &props.caption {
if f.kind != FieldKind::PushButton {
@@ -120,6 +159,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
return invalid(format!("{name} is locked: unlock it first"));
}
if let Some(options) = export_options.as_ref()
&& *props == (FieldProps { export_value: props.export_value.clone(), ..FieldProps::default() })
{
doc.update_dict(f.obj, |d| d.set(b"Opt".to_vec(), Object::Array(options.clone())))?;
return Ok(name.to_string());
}
if let Some(lock) = props.locked {
for w in &f.widgets {
let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0);
@@ -209,6 +254,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
// The export values in `/Opt` order once it is rewritten, for re-pointing `/I`.
let mut new_exports: Option<Vec<String>> = None;
let mut kept = Vec::new();
if let Some(options) = &export_options {
d.set(b"Opt".to_vec(), Object::Array(options.clone()));
}
if let Some(opts) = &props.options {
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
new_exports = Some(opts.clone());
+42 -4
View File
@@ -17,18 +17,24 @@ const MAX_LINKS: usize = 1 << 20;
pub struct CalculatedField {
/// The field's full name.
pub name: String,
/// The field object. Two calculated fields can share a name in a malformed form; this tells
/// them apart.
pub obj: ObjRef,
/// The fields its formula reads, each once, as the formula names them: a name also stands for
/// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here.
pub reads: Vec<String>,
/// `/CO` lists it. A field it leaves out calculates after the listed ones.
pub listed: bool,
/// What is wrong with its place in the order.
/// What is wrong with its formula or its place in the order.
pub issues: Vec<CalculationIssue>,
}
/// Something wrong with a calculated field's place in the calculation order.
/// Something wrong with a calculated field's formula or its place in the calculation order.
#[derive(Clone, Debug, PartialEq)]
pub enum CalculationIssue {
/// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft,
/// or mistyped), so the formula gets no value there.
MissingField { field: String },
/// It reads `field`, which calculates after it, so it uses that field's previous value.
RunsTooEarly { field: String },
/// Its formula reads its own result: it reads `fields`, which read it back (itself, when it
@@ -37,6 +43,9 @@ pub enum CalculationIssue {
/// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers
/// may not calculate it at all.
NotInOrder,
/// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those
/// aren't in `reads`, so the order isn't checked against them.
BuiltNames,
}
/// A form's calculation order and what is wrong with it.
@@ -152,11 +161,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
.collect();
let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads);
let component = components(&links);
let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
all.sort_unstable();
let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default();
let mut out = Vec::with_capacity(calculated.len());
for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() {
let own = component.get(k).copied();
let mut issues = Vec::new();
let mut issues: Vec<CalculationIssue> =
reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect();
let read = links.get(k).map(Vec::as_slice).unwrap_or_default();
for &j in read {
if j > k && component.get(j).copied() != own {
@@ -172,12 +184,25 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
if !listed {
issues.push(CalculationIssue::NotInOrder);
}
out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues });
if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) {
issues.push(CalculationIssue::BuiltNames);
}
out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues });
}
let fixed = fixed_order(&links, &component);
CalculationOrder { fields: out, extra, fixed }
}
/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a
/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts'
/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so
/// a read of one gets no value and is missing too.
fn found(names: &[&str], name: &str) -> bool {
let prefix = format!("{name}.");
let under = names.partition_point(|n| *n < prefix.as_str());
names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix))
}
/// For each calculated field (by its place in `names`), the places of the calculated fields it
/// reads, sorted. A read name finds the field of that name and the fields under it, as
/// [`crate::recalculate`] looks values up.
@@ -383,6 +408,19 @@ mod tests {
assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1");
}
#[test]
fn a_read_name_finds_a_field_or_a_group() {
let mut names = vec!["price", "row.1", "row.2", "rows", "total"];
names.sort_unstable();
for name in ["price", "row", "row.1", "rows"] {
assert!(found(&names, name), "{name}");
}
// A widget name finds nothing: recalculation and getField don't resolve it either.
for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] {
assert!(!found(&names, name), "{name}");
}
}
/// Two thousand formulas each reading a group of two thousand fields would be four million
/// reads; the checks stop at their cap.
#[test]
+9
View File
@@ -220,6 +220,15 @@ impl Field {
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
}
/// Values for data export, resolving button appearance names through /Opt.
pub fn export_values(&self) -> Vec<String> {
if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) {
self.value.iter().map(|v| self.export_for_state(v).to_string()).collect()
} else {
self.value.clone()
}
}
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
+129
View File
@@ -1711,6 +1711,73 @@ fn the_calculation_order_is_checked_fixed_and_kept() {
assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total");
}
/// `set_props` refuses a notation it couldn't store as written before it writes anything: the
/// tooltip given with it, which is written before the calculation, isn't set either.
#[test]
fn a_notation_that_cannot_be_stored_changes_nothing() {
use crate::af::Calculate;
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 [5 0 R] >>".into(),
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 700 140 711] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
for n in ["", "a EVCALC b", "a */ b", "a\\"] {
let props = FieldProps { tooltip: Some("Total".into()), calculate: Some(Calculate::Notation(n.into())), ..Default::default() };
assert!(set_props(&mut doc, "total", &props).is_err(), "{n:?}");
let all = fields(&doc);
let f = field(&all, "total");
assert_eq!((f.tooltip.as_deref(), &f.actions.calculate), (None, &Calculate::None), "{n:?} changed nothing");
}
}
/// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it
/// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a
/// script that builds the names it reads. Neither is an order Fix order can fix.
#[test]
fn missing_fields_and_built_names_are_flagged() {
use crate::af::{CalcOp, Calculate};
use crate::calc_order::CalculationIssue::{BuiltNames, MissingField};
let mut doc = one_page();
let text = NewField::Text { multiline: false };
for (i, name) in ["qty", "gone", "sum", "notation", "script", "built"].into_iter().enumerate() {
let y = 700.0 - 40.0 * i as f64;
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
}
let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() };
set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap();
set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap();
// qty has one widget, but a widget name is no field: recalculation and getField give qty.0
// nothing, so it is as missing as qty.1.
let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;";
set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap();
set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap();
let issues = |doc: &Document| {
let order = calculation_order(doc, &fields(doc));
assert!(!order.fixable(), "nothing Fix order can fix");
order.fields.into_iter().map(|f| (f.name, f.issues)).collect::<Vec<_>>()
};
let expected = |sum: Vec<_>| {
vec![
("sum".to_owned(), sum),
("notation".to_owned(), vec![MissingField { field: "nope".into() }]),
(
"script".to_owned(),
vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.0".into() }, MissingField { field: "qty.1".into() }],
),
("built".to_owned(), vec![BuiltNames]),
]
};
assert_eq!(issues(&doc), expected(vec![]));
delete_field(&mut doc, "gone").unwrap();
assert_eq!(issues(&doc), expected(vec![MissingField { field: "gone".into() }]), "a deleted field leaves the sum reading nothing");
assert_eq!(issues(&reopen(&doc)), expected(vec![MissingField { field: "gone".into() }]));
let order = calculation_order(&doc, &fields(&doc));
assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read");
}
/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an
/// entry repeated, a reference to nothing and a number. The order reads around them, and setting
/// the order writes `/CO` afresh.
@@ -1775,6 +1842,12 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() {
};
let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok);
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]);
// Each row is its own field object, so a caller can find the right formula for each dup.
let all = fields(&doc);
for c in calculation_order(&doc, &all).fields {
let f = all.iter().find(|f| f.obj == c.obj).unwrap();
assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone()));
}
let current: Vec<String> = ["dup", "src", "dup"].map(String::from).into();
set_calculation_order(&mut doc, &current).unwrap();
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged");
@@ -5356,3 +5429,59 @@ endstream".into(),
assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value);
}
}
#[test]
fn editing_button_export_values_preserves_selection_and_appearance() {
let mut doc = fixture();
set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap();
let before = field(&fields(&doc), "size").clone();
let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect();
set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap();
let after = field(&fields(&doc), "size").clone();
assert_eq!(after.value, before.value);
assert_eq!(after.default, before.default);
assert_eq!(after.widgets, before.widgets);
assert_eq!(after.export_of(0), Some("Small å"));
assert_eq!(after.export_of(1), before.export_of(1));
assert_eq!(after.export_values(), vec!["Small å"]);
assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::<Vec<_>>());
let mut saved = reopen(&doc);
assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å"));
set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap();
reset(&mut saved, None).unwrap();
assert_eq!(field(&fields(&saved), "size").value, before.default);
set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap();
let check = field(&fields(&doc), "agree").clone();
assert_eq!(check.value, vec!["Yes"]);
assert_eq!(check.export_values(), vec!["Accepted"]);
assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted"));
}
#[test]
fn invalid_button_export_edits_leave_document_unchanged() {
let mut doc = fixture();
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] {
assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err());
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
}
#[test]
fn locked_button_export_edits_leave_the_document_unchanged() {
for name in ["agree", "size"] {
let mut doc = fixture();
set_props(&mut doc, name, &FieldProps { locked: Some(true), ..Default::default() }).unwrap();
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let error = set_props(
&mut doc,
name,
&FieldProps { export_value: Some((0, "Changed".into())), tooltip: Some("Must not change".into()), ..Default::default() },
)
.unwrap_err();
assert!(error.to_string().contains("locked"));
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
}
+4
View File
@@ -83,6 +83,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
art Flate can be smaller. Not yet: JBIG2, inline images, an image's own `/SMask` or `/Mask`
image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767
samples.
Lossless JBIG2 is also available as Monochrome Images compression, with a bounded generic-region encoder and no symbol substitution. Export reads its embedded images and bounded shared globals.
The JBIG2 choice now uses a bounded exact-copy symbol dictionary when smaller than the generic-region result; lossy symbol substitution is not used.
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
+32 -3
View File
@@ -1010,7 +1010,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
// Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind
// ASCII85Decode, say, can be smaller stored again as Group 4 alone.
let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1;
if (retain || g4_alone) && !downsample {
if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample {
return None;
}
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?;
@@ -1039,7 +1039,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
Some(out) => (out, nw, nh),
None => (data, w, h),
};
if (retain || g4_alone) && !resampled {
if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled {
return None;
}
let mut dict = packed.unwrap_or_else(|| d.clone());
@@ -1062,9 +1062,18 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
} else {
None
};
let jbig = if settings.compression == MonoCompression::Jbig2 {
pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| {
let mut dict = dict.clone();
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
Stream::from_raw(dict, coded)
})
} else {
None
};
let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data));
// Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters.
let best = [g4, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?;
let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?;
(best.0 < crate::stored_len(s)?).then_some((best.1, resampled))
}
@@ -1092,6 +1101,7 @@ pub(crate) fn run(
let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect();
refs.sort_by_key(|(r, _)| r.num);
let total = refs.len();
let mut jbig2 = false;
for (done, (r, ppi)) in refs.into_iter().enumerate() {
if !progress(Stage::Images { done, total }) {
return Err(OptimizeError::Cancelled);
@@ -1114,6 +1124,7 @@ pub(crate) fn run(
} else {
report.images_recompressed += 1;
}
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
doc.set(r, Object::Stream(new));
continue;
}
@@ -1133,6 +1144,7 @@ pub(crate) fn run(
processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b))
});
if let Some((new, resampled)) = packed.filter(|_| packed_wins) {
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
if resampled {
@@ -1160,6 +1172,23 @@ pub(crate) fn run(
if !progress(Stage::Images { done: total, total }) {
return Err(OptimizeError::Cancelled);
}
if jbig2 {
at_least_pdf_1_4(doc)?;
}
Ok(())
}
/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4
/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2
/// §7.7.2). A full save with object streams writes a 1.5 header anyway.
fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> {
let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..];
let Some(root) = doc.root() else { return Ok(()) };
let version = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned());
let catalog = version.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec));
if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) {
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?;
}
Ok(())
}
+5 -1
View File
@@ -13,7 +13,7 @@
//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax,
//! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images
//! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a
//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4, or 1-bit
//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit
//! Flate once resampled or packed, when that is smaller.
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
//! print settings.
@@ -127,6 +127,10 @@ pub enum Compression {
pub enum MonoCompression {
/// CCITT Group 4 (lossless), when smaller than the image as it is.
CcittG4,
/// JBIG2, lossless (a generic region, or symbols when the image's marks repeat), when smaller
/// than the image as it is. JBIG2Decode is PDF 1.4: an older document gets `/Version /1.4` in
/// its catalog.
Jbig2,
/// Keep each image's own compression.
Retain,
}
+184
View File
@@ -1911,6 +1911,190 @@ fn group_3_fax_images_become_group_4() {
}
}
/// The images of a document that are images, by object number.
fn image_refs(doc: &Document) -> Vec<ObjRef> {
doc.object_numbers()
.into_iter()
.map(|num| ObjRef { num, generation: doc.generation(num) })
.filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")))
.collect()
}
/// Black-and-white images as lossless JBIG2: the page looks the same to the pixel (so
/// JBIG2Decode gives black as 0, as the encoder assumes), and scan-like text comes out smaller
/// than as Group 4. JBIG2Decode is PDF 1.4, so a PDF 1.3 document gets `/Version /1.4` in its
/// catalog.
#[test]
fn black_and_white_images_become_jbig2() {
let mut doc = Document::new_empty();
let (w, h) = (1200, 480);
let text = bilevel(w, h, false);
let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
let decode = Object::Array(vec![1.into(), 0.into()]);
let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]);
let indexed_cs = Object::Array(vec![
Object::name("Indexed"),
Object::name("DeviceRGB"),
1.into(),
Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])),
]);
let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]);
page(
&mut doc,
&[("G", gray), ("M", mask), ("I", indexed)],
"q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q",
);
// The same document, as PDF 1.3.
let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
bytes[..8].copy_from_slice(b"%PDF-1.3");
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.version(), "1.3");
let before = render(&doc);
let mut g4 = doc.clone();
let as_g4 = optimize(&mut g4, &Settings::default()).unwrap();
let mut jbig2 = doc.clone();
let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() };
let r = optimize(&mut jbig2, &settings).unwrap();
assert_eq!(r.images_recompressed, 3);
assert!(r.image_bytes_after < as_g4.image_bytes_after, "JBIG2 {} bytes, Group 4 {}", r.image_bytes_after, as_g4.image_bytes_after);
let images: Vec<Stream> = image_refs(&jbig2).into_iter().map(|i| stream(&jbig2, i)).collect();
assert_eq!(images.len(), 3);
for s in &images {
assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false));
}
assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays");
let indexed_kept =
|s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).and_then(|a| a.first()).and_then(Object::as_name) == Some(b"Indexed");
assert!(images.iter().any(indexed_kept), "the colour space stays");
let catalog = jbig2.root().and_then(|r| jbig2.get(r).as_dict().cloned()).unwrap();
assert_eq!(catalog.name(b"Version"), Some(&b"1.4"[..]));
assert!(g4.root().and_then(|r| g4.get(r).as_dict().cloned()).is_some_and(|c| !c.contains(b"Version")), "Group 4 is PDF 1.0");
assert!(render(&jbig2) == before, "the page looks the same");
// The integration also supports changing an already-CCITT image to lossless JBIG2.
let mut from_g4 = g4.clone();
let converted = optimize(&mut from_g4, &settings).unwrap();
assert_eq!(converted.images_recompressed, 3);
assert!(render(&from_g4) == before, "CCITT decoded and re-encoded as JBIG2 keeps the picture");
}
/// A catalog `/Version` given by reference is read through it: a PDF 1.3 file whose catalog says
/// 1.7 by reference keeps 1.7 when its images become JBIG2, rather than being lowered to 1.4; one
/// whose catalog says 1.3 by reference gets 1.4.
#[test]
fn a_catalog_version_given_by_reference_is_read() {
for (catalog, after) in [("1.7", "1.7"), ("1.3", "1.4")] {
let mut doc = Document::new_empty();
let (w, h) = (1200, 480);
let text = bilevel(w, h, false);
let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
page(&mut doc, &[("Im", im)], "q 300 0 0 120 20 660 cm /Im Do Q");
let version = doc.add(Object::name(catalog));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::Ref(version))).unwrap();
let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
bytes[..8].copy_from_slice(b"%PDF-1.3");
let mut doc = Document::open(Arc::new(bytes)).unwrap();
let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() };
let r = optimize(&mut doc, &settings).unwrap();
assert_eq!(r.images_recompressed, 1);
let root = doc.root().unwrap();
let v = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()).unwrap();
assert_eq!(doc.resolve(&v).as_name(), Some(after.as_bytes()), "catalog {catalog} by reference");
}
}
/// Text as a renderer draws it at 300 dpi: lines 48 rows apart of a few letter shapes, each
/// `#` a 3 × 3 block, three columns apart with a wider gap between words, every copy of a letter
/// the same to the pixel.
fn rendered_text(w: u32, h: u32) -> Vec<u8> {
const LETTERS: [&[&str]; 5] = [
&["##", "##", "##", "##", "##", "##", "##", "##", "##", "##"],
&["......", ".####.", "##..##", "##..##", "##..##", "##..##", ".####."],
&["##....", "##....", "##....", "#####.", "##..##", "##..##", "##..##", "#####."],
&["##..##", "##..##", "##..##", "##..##", ".#####"],
&["##", "##", "..", "##", "##", "##", "##", "##"],
];
let stride = (w as usize).div_ceil(8);
let mut data = vec![0xFF; stride * h as usize];
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for baseline in (32..h as usize).step_by(48) {
let mut x = 8;
while x + 20 < w as usize {
let letter = LETTERS[(next() % 5) as usize];
for (dy, row) in letter.iter().enumerate() {
for (dx, c) in row.bytes().enumerate() {
for (py, px) in (0..9).map(|k| (baseline + 1 + 3 * dy + k / 3 - 3 * letter.len(), x + 3 * dx + k % 3)) {
if c == b'#' {
data[py * stride + px / 8] &= !(0x80 >> (px % 8));
}
}
}
}
x += 3 * letter[0].len() + 3 + if next() % 5 == 0 { 12 } else { 0 };
}
}
data
}
/// Text whose letters repeat exactly, as rendered text does, becomes JBIG2 symbols: each letter
/// stored once and its copies placed, several times smaller than JBIG2's generic coding of the
/// same image, and the page looks the same.
#[test]
fn rendered_text_becomes_jbig2_symbols() {
let mut doc = Document::new_empty();
let (w, h) = (600, 480);
let text = rendered_text(w, h);
let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
// One image pixel a device pixel at scale 1, so a wrong pixel shows.
page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q");
let before = render(&doc);
let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() };
let r = optimize(&mut doc, &settings).unwrap();
assert_eq!(r.images_recompressed, 1);
let s = stream(&doc, im);
assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]));
let generic = pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap();
assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, generic {}", s.raw.len(), generic.len());
assert!(render(&doc) == before, "the page looks the same");
}
/// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the
/// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols.
#[test]
fn a_dense_page_of_rendered_text_looks_the_same_as_jbig2_symbols() {
let mut doc = Document::new_empty();
let (w, h) = (2550, 3300);
let text = rendered_text(w, h);
let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
page(&mut doc, &[("Im", im)], "q 612 0 0 792 0 0 cm /Im Do Q");
let before = render(&doc);
let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() };
let r = optimize(&mut doc, &settings).unwrap();
assert_eq!(r.images_recompressed, 1);
let s = stream(&doc, im);
assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]));
assert!(s.raw.len() * 2 < pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap().len(), "symbol coded");
// The copies take several text regions (segment type 7, ISO/IEC 14492 7.3), read from the
// segment headers (7.2): number, flags, referred-to count and numbers, page, data length.
let mut regions = 0;
let mut at = 0;
while let Some(header) = s.raw.get(at..at + 6) {
let refers = usize::from(header[5] >> 5);
let length = u32::from_be_bytes(s.raw[at + 7 + refers..at + 11 + refers].try_into().unwrap()) as usize;
regions += usize::from(header[4] & 0x3F == 7);
at += 11 + refers + length;
}
assert!(regions > 1, "{regions} text regions");
assert!(render(&doc) == before, "the page looks the same");
}
#[test]
fn settings_choose_what_happens() {
let mut doc = Document::new_empty();
+3 -3
View File
@@ -927,7 +927,7 @@ struct PageFrame<'a> {
current_overlap: f32,
current_height: f32,
pressed_page: Option<usize>,
calc_hover: Option<String>,
calc_hover: Option<crate::calc_order_ui::Focus>,
}
/// Visible heights (points) closer than this are equal: one page size can meet the viewport a
@@ -1732,8 +1732,8 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_
}
if calc_order {
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
if let Some(name) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) {
frame.calc_hover = Some(name);
if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) {
frame.calc_hover = Some(field);
}
}
} else {
+7 -3
View File
@@ -1741,9 +1741,13 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
// The widget being edited, by object: a radio button that joins a group by its new name
// is appended after the group's own buttons, so its index changes.
let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj));
if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
&& let Some(i) = app.active
{
let applied = app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) });
if !applied && app.active_ids().is_some() {
// A refused edit (its reason is in the notice) leaves the dialog open with what was
// typed into it, as the several-fields dialog does, so nothing is lost meanwhile.
app.field_props = Some(d);
req.close = false;
} else if applied && let Some(i) = app.active {
// Keep the (possibly renamed) field selected.
// A dotted name is the full name; a plain one renames the field within its parent.
let typed = d.name.trim();
+23 -6
View File
@@ -231,13 +231,17 @@ impl PdfCraftApp {
if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) {
ed.claim_typing(&ctx, doc);
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false };
ed.text = text;
let before = std::mem::replace(&mut ed.text, text);
ed.selection = None;
ed.follow_typed_rows(&ctx, &before);
return true;
}
if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) {
d.claim_typing(&ctx, doc);
let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false };
d.text = text;
let before = std::mem::replace(&mut d.text, text);
d.selection = None;
crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]);
return true;
}
false
@@ -262,13 +266,17 @@ impl PdfCraftApp {
if let Some(ed) = view.line_editor.as_mut() {
ed.claim_typing(&ctx, doc);
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() };
ed.text = text;
let before = std::mem::replace(&mut ed.text, text);
ed.selection = None;
ed.follow_typed_rows(&ctx, &before);
return true;
}
if let Some(d) = view.content.draft.as_mut() {
d.claim_typing(&ctx, doc);
let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() };
d.text = text;
let before = std::mem::replace(&mut d.text, text);
d.selection = None;
crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]);
return true;
}
false
@@ -1037,6 +1045,7 @@ mod revert_draft_tests {
text: text.into(),
style: default_style(),
focus: true,
selection: None,
typing_from: None,
};
assert!(!app.has_unsaved_work(0));
@@ -1063,8 +1072,16 @@ mod revert_draft_tests {
let mut app = PdfCraftApp::new();
app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap();
let rect = [72.0, 72.0, 272.0, 92.0];
app.views[0].content.draft =
Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true, typing_from: None });
app.views[0].content.draft = Some(TextDraft {
page: 0,
index: None,
rect,
text: "Typed".into(),
style: default_style(),
focus: true,
selection: None,
typing_from: None,
});
let ctx = egui::Context::default();
if quit {
assert!(!app.request_quit(&ctx));
+2
View File
@@ -327,6 +327,7 @@ fn a_refused_text_box_is_put_back_whole() {
text: "Typed".into(),
style,
focus: false,
selection: None,
typing_from: None,
});
app.undo();
@@ -351,6 +352,7 @@ fn save_commits_the_open_text_box() {
text: "Typed text".into(),
style,
focus: true,
selection: None,
typing_from: None,
});
+13
View File
@@ -541,6 +541,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. يقرأ {names} التي تقرؤه بدورها. لا يمكن لأي ترتيب إصلاح الحلقة: غيّر إحدى الصيغ.
Not in order خارج الترتيب
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. غير موجود في ترتيب الحساب المحفوظ في ملف PDF: قد لا تحسبه برامج عرض PDF الأخرى.
Reads {name}, which the form doesn't have, so it gets no value there. يقرأ {name}، وهو غير موجود في النموذج، لذا لا يحصل منه على أي قيمة.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. يقرأ سكربته أيضًا حقولًا يبني أسماءها أثناء التشغيل، لذا لا يمكن التحقق من هذه القراءات.
Missing field حقل مفقود
Unchecked reads قراءات غير مفحوصة
{field} reads fields named as it runs يقرأ {field} حقولًا تُسمّى أثناء التشغيل
Some names are built as it runs تُبنى بعض الأسماء أثناء التشغيل
Back to Prepare a form العودة إلى تجهيز نموذج
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. لا يحسب أي حقل في هذا النموذج قيمته بعد. امنح حقلًا عملية حساب في علامة التبويب «الحساب» ضمن خصائصه، وسيظهر هنا.
{names} and {last} {names} و{last}
@@ -599,6 +605,7 @@
Grayscale Images الصور الرمادية
Monochrome Images الصور أحادية اللون
CCITT Group 4 CCITT المجموعة 4
JBIG2 (lossless) JBIG2 (بدون فقدان)
Bicubic downsampling to تقليل العينات بطريقة Bicubic إلى
for images above للصور التي تتجاوز
Compression الضغط
@@ -2436,6 +2443,11 @@ animation effects Off معطّلة
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، الذي يقرؤه.|{field} يُحسب قبل {fields}، اللذين يقرؤهما.|{field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، التي يقرؤها.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} من الحقول الأخرى تُحسب مبكرًا جدًا.|حقل آخر ({n}) يُحسب مبكرًا جدًا.|حقلان آخران ({n}) يُحسبان مبكرًا جدًا.|{n} حقول أخرى تُحسب مبكرًا جدًا.|{n} حقلًا آخر يُحسب مبكرًا جدًا.|{n} من الحقول الأخرى تُحسب مبكرًا جدًا.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجود في الترتيب المحفوظ في PDF، لذا قد لا تحسبه عارضات PDF الأخرى.|{fields} غير موجودين في الترتيب المحفوظ في PDF، لذا قد لا تحسبهما عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} من الصيغ تقرأ حقولًا غير موجودة في النموذج|صيغة واحدة ({n}) تقرأ حقلًا غير موجود في النموذج|صيغتان ({n}) تقرآن حقولًا غير موجودة في النموذج|{n} صيغ تقرأ حقولًا غير موجودة في النموذج|{n} صيغة تقرأ حقولًا غير موجودة في النموذج|{n} من الصيغ تقرأ حقولًا غير موجودة في النموذج
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقل {fields}، وهو غير موجود في النموذج.|يقرأ {field} الحقلين {fields}، وهما غير موجودين في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} من الصيغ الأخرى تقرأ حقولًا مفقودة.|صيغة أخرى ({n}) تقرأ حقلًا مفقودًا.|صيغتان أخريان ({n}) تقرآن حقولًا مفقودة.|{n} صيغ أخرى تقرأ حقولًا مفقودة.|{n} صيغة أخرى تقرأ حقولًا مفقودة.|{n} من الصيغ الأخرى تقرأ حقولًا مفقودة.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.
@plural {fields} is missing|{fields} are missing {fields} مفقودة|{fields} مفقود|{fields} مفقودان|{fields} مفقودة|{fields} مفقودة|{fields} مفقودة
Scan مسح ضوئي
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. وقّع على ورقة بيضاء والتقط لها صورة في إضاءة ساطعة ومتساوية (استخدم الفلاش). اختر الصورة أو أفلتها هنا: يعثر PdfCraft على التوقيع ويزيل الورق.
Choose photo… اختيار صورة…
@@ -2846,3 +2858,4 @@ certificate Version الإصدار
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply خيارات هذا الرد
Export value قيمة التصدير
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -202,6 +202,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Чете {names}, които на свой ред го четат. Никой ред не може да поправи цикъл: променете една от формулите.
Not in order Извън реда
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Липсва в реда на изчисляване, записан в PDF файла: други програми за PDF може да не го изчисляват.
Reads {name}, which the form doesn't have, so it gets no value there. Чете {name}, което го няма във формуляра, затова не получава стойност оттам.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Скриптът му чете и полета, чиито имена съставя по време на изпълнение, затова тези четения не могат да бъдат проверени.
Missing field Липсващо поле
Unchecked reads Непроверени четения
{field} reads fields named as it runs {field} чете полета, именувани по време на изпълнение
Some names are built as it runs Някои имена се съставят по време на изпълнение
Back to Prepare a form Назад към „Подготви формуляр“
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Все още нито едно поле в този формуляр не изчислява стойността си. Задайте изчисление на поле в раздела „Изчисляване“ на свойствата му и то ще се покаже тук.
{names} and {last} {names} и {last}
@@ -261,6 +267,7 @@
Grayscale Images Изображения в сива скала
Monochrome Images Монохромни изображения
CCITT Group 4 CCITT група 4
JBIG2 (lossless) JBIG2 (без загуби)
Bicubic downsampling to Бикубично понижаване на резолюцията до
for images above за изображения над
Compression Компресия
@@ -2378,6 +2385,11 @@ animation effects Off Изключени
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} се изчислява преди {fields}, което чете.|{field} се изчислява преди {fields}, които чете.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Още {n} поле се изчислява твърде рано.|Още {n} полета се изчисляват твърде рано.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} липсва в реда, запазен в PDF файла, затова други програми за PDF може да не го изчислят.|{fields} липсват в реда, запазен в PDF файла, затова други програми за PDF може да не ги изчислят.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула чете поле, което го няма във формуляра|{n} формули четат полета, които ги няма във формуляра
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} чете {fields}, което го няма във формуляра.|{field} чете {fields}, които ги няма във формуляра.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Още {n} формула чете липсващо поле.|Още {n} формули четат липсващи полета.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} съставя някои имена на полета по време на изпълнение, затова тези четения не се проверяват.|{fields} съставят някои имена на полета по време на изпълнение, затова тези четения не се проверяват.
@plural {fields} is missing|{fields} are missing {fields} липсва|{fields} липсват
Scan Сканиране
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Подпишете се на лист бяла хартия и го снимайте на ярка, равномерна светлина (със светкавица). Изберете снимката или я пуснете тук: PdfCraft открива подписа и премахва хартията.
Choose photo… Избор на снимка…
@@ -2533,3 +2545,4 @@ animation effects Off Изключени
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Опции за този отговор
Export value Стойност за експортиране
Can't render this file because it contains an unexpected character in line 8 and column 16.
+12
View File
@@ -562,6 +562,7 @@
Grayscale Images Imatges en escala de grisos
Monochrome Images Imatges monocromes
CCITT Group 4 CCITT grup 4
JBIG2 (lossless) JBIG2 (sense pèrdua)
Bicubic downsampling to Reducció bicúbica de resolució a
for images above per a imatges de més de
Compression Compressió
@@ -2730,6 +2731,12 @@ theme Appearance Aspecte
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Reads {names}, which read it back. No order can fix a loop: change one of the formulas.
Not in order Not in order
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
Back to Prepare a form Back to Prepare a form
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here.
{names} and {last} {names} and {last}
@@ -2758,6 +2765,11 @@ theme Appearance Aspecte
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} more field calculates too early.|{n} more fields calculate too early.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original.
Bulleted list Bulleted list
Can't render this file because it contains an unexpected character in line 7 and column 3.
+13
View File
@@ -569,6 +569,7 @@
Grayscale Images وێنە خۆڵەمێشییەکان
Monochrome Images وێنە یەکڕەنگەکان
CCITT Group 4 CCITT گرووپی 4
JBIG2 (lossless) JBIG2 (بێ لەدەستدان)
Bicubic downsampling to کەمکردنەوەی نموونە بە شێوازی Bicubic بۆ
for images above بۆ ئەو وێنانەی سەرووی
Compression پەستاندن
@@ -2762,3 +2763,15 @@ certificate Version وەشان
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply هەڵبژاردەکانی ئەم وەڵامە
# Untranslated: English text until a translator fills these in.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -625,6 +625,12 @@ key Space Mezerník
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Čte {names}, která ho čtou zpět. Žádné pořadí smyčku neopraví: změňte jeden ze vzorců.
Not in order Mimo pořadí
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Chybí v pořadí výpočtů uloženém v PDF: jiné prohlížeče PDF ho možná nepočítají.
Reads {name}, which the form doesn't have, so it gets no value there. Čte {name}, které formulář nemá, takže odtud nedostane žádnou hodnotu.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jeho skript čte i pole, jejichž názvy sestavuje za běhu, takže tato čtení nelze zkontrolovat.
Missing field Chybí pole
Unchecked reads Neověřená čtení
{field} reads fields named as it runs {field} čte pole pojmenovaná za běhu
Some names are built as it runs Některé názvy se sestavují za běhu
Back to Prepare a form Zpět na Připravit formulář
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Žádné pole tohoto formuláře zatím nepočítá svou hodnotu. Zadejte poli výpočet na kartě Výpočet v jeho vlastnostech a zobrazí se zde.
{names} and {last} {names} a {last}
@@ -683,6 +689,7 @@ key Space Mezerník
Grayscale Images Obrázky ve stupních šedi
Monochrome Images Monochromatické obrázky
CCITT Group 4 CCITT skupina 4
JBIG2 (lossless) JBIG2 (bezeztrátová)
Bicubic downsampling to Bikubické převzorkování na
for images above u obrázků nad
Compression Komprese
@@ -2489,6 +2496,11 @@ animation effects Off Vypnuto
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se počítá před {fields}, které čte.|{field} se počítá před {fields}, která čte.|{field} se počítá před {fields}, která čte.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Ještě {n} pole se počítá příliš brzy.|Ještě {n} pole se počítají příliš brzy.|Ještě {n} polí se počítá příliš brzy.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} chybí v pořadí uloženém v PDF, takže ho jiné prohlížeče PDF nemusí vypočítat.|{fields} chybí v pořadí uloženém v PDF, takže je jiné prohlížeče PDF nemusí vypočítat.|{fields} chybí v pořadí uloženém v PDF, takže je jiné prohlížeče PDF nemusí vypočítat.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} vzorec čte pole, které formulář nemá|{n} vzorce čtou pole, která formulář nemá|{n} vzorců čte pole, která formulář nemá
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} čte {fields}, které formulář nemá.|{field} čte {fields}, která formulář nemá.|{field} čte {fields}, která formulář nemá.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ještě {n} vzorec čte chybějící pole.|Ještě {n} vzorce čtou chybějící pole.|Ještě {n} vzorců čte chybějící pole.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} sestavuje některé názvy polí za běhu, takže tato čtení nejsou ověřena.|{fields} sestavují některé názvy polí za běhu, takže tato čtení nejsou ověřena.|{fields} sestavují některé názvy polí za běhu, takže tato čtení nejsou ověřena.
@plural {fields} is missing|{fields} are missing {fields} chybí|{fields} chybí|{fields} chybí
Scan Skenovat
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podepište se na bílý list papíru a vyfoťte ho při jasném, rovnoměrném světle (použijte blesk). Vyberte fotografii nebo ji sem přetáhněte: PdfCraft najde podpis a odstraní papír.
Choose photo… Vybrat fotografii…
@@ -2877,3 +2889,4 @@ certificate Version Verze
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Možnosti této odpovědi
Export value Exportní hodnota
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -514,6 +514,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Liest {names}, die es wieder lesen. Keine Reihenfolge kann eine Schleife beheben: Ändere eine der Formeln.
Not in order Nicht in der Reihenfolge
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Fehlt in der im PDF gespeicherten Berechnungsreihenfolge: Andere PDF-Viewer berechnen es womöglich nicht.
Reads {name}, which the form doesn't have, so it gets no value there. Liest {name}, das es im Formular nicht gibt, und erhält dort deshalb keinen Wert.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Sein Skript liest auch Felder, deren Namen es erst beim Ausführen bildet. Diese Lesezugriffe lassen sich nicht prüfen.
Missing field Feld fehlt
Unchecked reads Ungeprüfte Lesezugriffe
{field} reads fields named as it runs {field} liest Felder, deren Namen erst beim Ausführen entstehen
Some names are built as it runs Manche Namen entstehen erst beim Ausführen
Back to Prepare a form Zurück zu „Formular vorbereiten“
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Noch berechnet kein Feld dieses Formulars seinen Wert. Gib einem Feld auf dem Tab „Berechnen“ seiner Eigenschaften eine Berechnung, dann erscheint es hier.
{names} and {last} {names} und {last}
@@ -572,6 +578,7 @@
Grayscale Images Graustufenbilder
Monochrome Images Monochrome Bilder
CCITT Group 4 CCITT Gruppe 4
JBIG2 (lossless) JBIG2 (verlustfrei)
Bicubic downsampling to Bikubische Neuberechnung auf
for images above für Bilder über
Compression Komprimierung
@@ -2416,6 +2423,11 @@ animation effects Off Aus
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} wird vor {fields} berechnet, das es liest.|{field} wird vor {fields} berechnet, die es liest.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} weiteres Feld wird zu früh berechnet.|{n} weitere Felder werden zu früh berechnet.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} fehlt in der im PDF gespeicherten Reihenfolge, daher berechnen andere PDF-Viewer es womöglich nicht.|{fields} fehlen in der im PDF gespeicherten Reihenfolge, daher berechnen andere PDF-Viewer sie womöglich nicht.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} Formel liest ein Feld, das es im Formular nicht gibt|{n} Formeln lesen Felder, die es im Formular nicht gibt
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} liest {fields}, das es im Formular nicht gibt.|{field} liest {fields}, die es im Formular nicht gibt.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} weitere Formel liest ein fehlendes Feld.|{n} weitere Formeln lesen fehlende Felder.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} bildet manche Feldnamen erst beim Ausführen, daher werden diese Lesezugriffe nicht geprüft.|{fields} bilden manche Feldnamen erst beim Ausführen, daher werden diese Lesezugriffe nicht geprüft.
@plural {fields} is missing|{fields} are missing {fields} fehlt|{fields} fehlen
Scan Scannen
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Unterschreiben Sie auf einem weißen Blatt Papier und fotografieren Sie es bei hellem, gleichmäßigem Licht (mit Blitz). Wählen Sie das Foto aus oder ziehen Sie es hierher: PdfCraft findet die Unterschrift und entfernt das Papier.
Choose photo… Foto auswählen…
@@ -2571,3 +2583,4 @@ animation effects Off Aus
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Optionen für diese Antwort
Export value Exportwert
Can't render this file because it contains an unexpected character in line 6 and column 51.
+13
View File
@@ -1860,6 +1860,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Διαβάζει τα {names}, που με τη σειρά τους το διαβάζουν. Καμία σειρά δεν διορθώνει έναν βρόχο: αλλάξτε έναν από τους τύπους.
Not in order Εκτός σειράς
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Λείπει από τη σειρά υπολογισμού που είναι αποθηκευμένη στο PDF: άλλα προγράμματα προβολής PDF ίσως δεν το υπολογίζουν.
Reads {name}, which the form doesn't have, so it gets no value there. Διαβάζει το {name}, που δεν υπάρχει στη φόρμα, οπότε δεν παίρνει τιμή από εκεί.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Η δέσμη ενεργειών του διαβάζει επίσης πεδία των οποίων τα ονόματα σχηματίζει κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν μπορούν να ελεγχθούν.
Missing field Λείπει πεδίο
Unchecked reads Μη ελεγμένες αναγνώσεις
{field} reads fields named as it runs Το {field} διαβάζει πεδία που ονομάζονται κατά την εκτέλεση
Some names are built as it runs Ορισμένα ονόματα σχηματίζονται κατά την εκτέλεση
Back to Prepare a form Επιστροφή στην Προετοιμασία φόρμας
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Κανένα πεδίο αυτής της φόρμας δεν υπολογίζει ακόμη την τιμή του. Δώστε σε ένα πεδίο υπολογισμό στην καρτέλα Υπολογισμός των ιδιοτήτων του και θα εμφανιστεί εδώ.
{names} and {last} {names} και {last}
@@ -1916,6 +1922,7 @@
Grayscale Images Εικόνες σε κλίμακα του γκρι
Monochrome Images Μονόχρωμες εικόνες
CCITT Group 4 CCITT Ομάδα 4
JBIG2 (lossless) JBIG2 (χωρίς απώλειες)
Bicubic downsampling to Δικυβική μείωση ανάλυσης σε
for images above για εικόνες πάνω από
ZIP ZIP
@@ -2412,6 +2419,11 @@ animation effects Off Ανενεργά
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} υπολογίζεται πριν από {fields}, που διαβάζει.|{field} υπολογίζεται πριν από {fields}, που διαβάζει.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Ακόμη {n} πεδίο υπολογίζεται πολύ νωρίς.|Ακόμη {n} πεδία υπολογίζονται πολύ νωρίς.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} λείπει από τη σειρά που είναι αποθηκευμένη στο PDF, οπότε άλλα προγράμματα PDF ίσως δεν το υπολογίσουν.|{fields} λείπουν από τη σειρά που είναι αποθηκευμένη στο PDF, οπότε άλλα προγράμματα PDF ίσως δεν τα υπολογίσουν.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} τύπος διαβάζει πεδίο που δεν υπάρχει στη φόρμα|{n} τύποι διαβάζουν πεδία που δεν υπάρχουν στη φόρμα
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. Το {field} διαβάζει το {fields}, που δεν υπάρχει στη φόρμα.|Το {field} διαβάζει τα {fields}, που δεν υπάρχουν στη φόρμα.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ακόμη {n} τύπος διαβάζει πεδίο που λείπει.|Ακόμη {n} τύποι διαβάζουν πεδία που λείπουν.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. Το {fields} σχηματίζει ορισμένα ονόματα πεδίων κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν ελέγχονται.|Τα {fields} σχηματίζουν ορισμένα ονόματα πεδίων κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν ελέγχονται.
@plural {fields} is missing|{fields} are missing Το {fields} λείπει|Τα {fields} λείπουν
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} όνομα πεδίου συγκρούεται με προηγούμενα αρχεία και θα μετονομαστεί: {list}|{n} ονόματα πεδίων συγκρούονται με προηγούμενα αρχεία και θα μετονομαστούν: {list}
Scan Σάρωση
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί.
@@ -2576,3 +2588,4 @@ animation effects Off Ανενεργά
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Επιλογές για αυτήν την απάντηση
Export value Τιμή εξαγωγής
Can't render this file because it contains an unexpected character in line 490 and column 9.
+13
View File
@@ -537,6 +537,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lee {names}, que a su vez lo leen. Ningún orden puede corregir un bucle: cambia una de las fórmulas.
Not in order Fuera del orden
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Falta en el orden de cálculo guardado en el PDF: otros visores de PDF podrían no calcularlo.
Reads {name}, which the form doesn't have, so it gets no value there. Lee {name}, que no existe en el formulario, así que no obtiene ningún valor de ahí.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Su script también lee campos cuyos nombres construye mientras se ejecuta, así que esas lecturas no se pueden comprobar.
Missing field Falta un campo
Unchecked reads Lecturas sin comprobar
{field} reads fields named as it runs {field} lee campos nombrados mientras se ejecuta
Some names are built as it runs Algunos nombres se construyen al ejecutarse
Back to Prepare a form Volver a Preparar un formulario
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ningún campo de este formulario calcula todavía su valor. Dale a un campo un cálculo en la pestaña Calcular de sus propiedades y aparecerá aquí.
{names} and {last} {names} y {last}
@@ -595,6 +601,7 @@
Grayscale Images Imágenes en escala de grises
Monochrome Images Imágenes monocromas
CCITT Group 4 CCITT grupo 4
JBIG2 (lossless) JBIG2 (sin pérdida)
Bicubic downsampling to Reducción de resolución bicúbica a
for images above para imágenes por encima de
Compression Compresión
@@ -2551,6 +2558,11 @@ animation effects Off Desactivados
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se calcula antes que {fields}, que lee.|{field} se calcula antes que {fields}, que lee.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} campo más se calcula demasiado pronto.|{n} campos más se calculan demasiado pronto.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} falta en el orden guardado en el PDF, así que otros visores de PDF podrían no calcularlo.|{fields} faltan en el orden guardado en el PDF, así que otros visores de PDF podrían no calcularlos.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} fórmula lee un campo que no existe en el formulario|{n} fórmulas leen campos que no existen en el formulario
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lee {fields}, que no existe en el formulario.|{field} lee {fields}, que no existen en el formulario.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} fórmula más lee un campo que falta.|{n} fórmulas más leen campos que faltan.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} construye algunos nombres de campo al ejecutarse, así que esas lecturas no se comprueban.|{fields} construyen algunos nombres de campo al ejecutarse, así que esas lecturas no se comprueban.
@plural {fields} is missing|{fields} are missing Falta {fields}|Faltan {fields}
Scan Escanear
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma en una hoja de papel blanco y hazle una foto con luz intensa y uniforme (usa el flash). Elige la foto o suéltala aquí: PdfCraft encuentra la firma y elimina el papel.
Choose photo… Elegir foto…
@@ -2893,3 +2905,4 @@ theme Appearance Aspecto
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opciones de esta respuesta
Export value Valor de exportación
Can't render this file because it contains an unexpected character in line 6 and column 60.
+12
View File
@@ -538,6 +538,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} را می‌خواند که به نوبه خود آن را می‌خوانند. هیچ ترتیبی حلقه را اصلاح نمی‌کند: یکی از فرمول‌ها را تغییر دهید.
Not in order نامرتب
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. در ترتیب محاسب‌های که در PDF ذخیره شده وجود ندارد: سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند.
Reads {name}, which the form doesn't have, so it gets no value there. {name} را می‌خواند که در فرم وجود ندارد، پس از آن مقداری نمی‌گیرد.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. اسکریپت آن فیلد‌هایی را هم می‌خواند که نامشان را هنگام اجرا می‌سازد، پس این خواندن‌ها قابل بررسی نیستند.
Missing field فیلد ناموجود
Unchecked reads خواندن‌های بررسی‌نشده
{field} reads fields named as it runs {field} فیلد‌هایی را می‌خواند که نامشان هنگام اجرا ساخته می‌شود
Some names are built as it runs برخی نام‌ها هنگام اجرا ساخته می‌شوند
Back to Prepare a form بازگشت به آماده‌سازی فرم
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. هنوز هیچ فیلدی در این فرم مقدارش را محاسبه نمی‌کند. به یک فیلد در برگه محاسبه از ویژگی‌هایش یک محاسبه بدهید تا اینجا نمایش داده شود.
{names} and {last} {names} و {last}
@@ -596,6 +602,7 @@
Grayscale Images تصاویر خاکستری
Monochrome Images تصاویر تک‌رنگ
CCITT Group 4 CCITT گروه ۴
JBIG2 (lossless) JBIG2 (بدون اتلاف)
Bicubic downsampling to نمونه برداری کاهشی دو مکعبی به
for images above برای تصاویر بالا‌تر از
Compression فشرده‌سازی
@@ -2516,6 +2523,11 @@ animation effects Off خاموش
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} پیش از {fields} محاسبه می‌شود، در حالی که آن را می‌خواند.|{field} پیش از {fields} محاسبه می‌شود، در حالی که آن‌ها را می‌خواند.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} فیلد دیگر زود‌تر از موعد محاسبه می‌شود.|{n} فیلد دیگر زود‌تر از موعد محاسبه می‌شوند.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} در ترتیب ذخیره‌شده در PDF وجود ندارد، بنابراین سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند.|{fields} در ترتیب ذخیره‌شده در PDF وجود ندارند، بنابراین سایر نمایشگر‌های PDF ممکن است آن‌ها را محاسبه نکنند.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} فرمول فیلدی را می‌خواند که در فرم وجود ندارد|{n} فرمول فیلد‌هایی را می‌خوانند که در فرم وجود ندارند
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} {fields} را می‌خواند که در فرم وجود ندارد.|{field} {fields} را می‌خواند که در فرم وجود ندارند.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} فرمول دیگر یک فیلد ناموجود را می‌خواند.|{n} فرمول دیگر فیلد‌های ناموجود را می‌خوانند.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} برخی نام‌های فیلد را هنگام اجرا می‌سازد، پس این خواندن‌ها بررسی نمی‌شوند.|{fields} برخی نام‌های فیلد را هنگام اجرا می‌سازند، پس این خواندن‌ها بررسی نمی‌شوند.
@plural {fields} is missing|{fields} are missing {fields} وجود ندارد|{fields} وجود ندارند
Scan اسکن
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. روی یک برگ کاغذ سفید امضا کنید و از آن در نور روشن و یکنواخت عکس بگیرید (از فلاش استفاده کنید). عکس را انتخاب کنید یا آن را اینجا رها کنید: PdfCraft امضا را پیدا می‌کند و کاغذ را حذف می‌کند.
Choose photo… انتخاب عکس…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
View File
@@ -562,6 +562,7 @@
Grayscale Images Harmaasävykuvat
Monochrome Images Mustavalkokuvat
CCITT Group 4 CCITT ryhmä 4
JBIG2 (lossless) JBIG2 (häviötön)
Bicubic downsampling to Bikuubinen alinäytteistys tarkkuuteen
for images above kuville, joiden tarkkuus yli
Compression Pakkaus
@@ -2742,6 +2743,12 @@ theme Appearance Teema
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lukee kenttiä {names}, jotka lukevat sitä takaisin. Mikään järjestys ei korjaa silmukkaa: muuta jotakin kaavoista.
Not in order Ei järjestyksessä
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Puuttuu PDF:ään tallennetusta laskentajärjestyksestä: muut PDF-katseluohjelmat eivät ehkä laske sitä.
Reads {name}, which the form doesn't have, so it gets no value there. Lukee kenttää {name}, jota lomakkeessa ei ole, joten se ei saa siitä arvoa.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Sen komentosarja lukee myös kenttiä, joiden nimet se muodostaa suorituksen aikana, joten näitä viittauksia ei voi tarkistaa.
Missing field Puuttuva kenttä
Unchecked reads Tarkistamattomat viittaukset
{field} reads fields named as it runs {field} lukee kenttiä, joiden nimet muodostuvat suorituksen aikana
Some names are built as it runs Osa nimistä muodostuu suorituksen aikana
Back to Prepare a form Takaisin kohtaan Valmistele lomake
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Mikään tämän lomakkeen kenttä ei vielä laske arvoaan. Anna kentälle laskenta sen ominaisuuksien Laskenta-välilehdellä, niin se näkyy tässä.
{names} and {last} {names} ja {last}
@@ -2769,6 +2776,11 @@ theme Appearance Teema
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} lasketaan ennen lukemaansa kenttää {fields}.|{field} lasketaan ennen lukemiaan kenttiä {fields}.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} muu kenttä lasketaan liian aikaisin.|{n} muuta kenttää lasketaan liian aikaisin.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} puuttuu PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske sitä.|{fields} puuttuvat PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske niitä.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} kaava lukee kenttää, jota lomakkeessa ei ole|{n} kaavaa lukee kenttiä, joita lomakkeessa ei ole
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lukee kenttää {fields}, jota lomakkeessa ei ole.|{field} lukee kenttiä {fields}, joita lomakkeessa ei ole.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} muu kaava lukee puuttuvaa kenttää.|{n} muuta kaavaa lukee puuttuvia kenttiä.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} muodostaa osan kenttien nimistä suorituksen aikana, joten näitä viittauksia ei tarkisteta.|{fields} muodostavat osan kenttien nimistä suorituksen aikana, joten näitä viittauksia ei tarkisteta.
@plural {fields} is missing|{fields} are missing {fields} puuttuu|{fields} puuttuvat
Bulleted list Luettelomerkit
Numbered list Numeroitu luettelo
Alignment Tasaus
Can't render this file because it contains an unexpected character in line 5 and column 47.
+13
View File
@@ -514,6 +514,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lit {names}, qui le lisent en retour. Aucun ordre ne peut corriger une boucle : modifiez l'une des formules.
Not in order Hors de l'ordre
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Absent de l'ordre de calcul enregistré dans le PDF : d'autres lecteurs PDF risquent de ne pas le calculer.
Reads {name}, which the form doesn't have, so it gets no value there. Lit {name}, qui n'existe pas dans le formulaire : aucune valeur n'en provient.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Son script lit aussi des champs dont il construit les noms à l'exécution : ces lectures ne peuvent pas être vérifiées.
Missing field Champ manquant
Unchecked reads Lectures non vérifiées
{field} reads fields named as it runs {field} lit des champs nommés à l'exécution
Some names are built as it runs Certains noms sont construits à l'exécution
Back to Prepare a form Retour à Préparer un formulaire
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Aucun champ de ce formulaire ne calcule encore sa valeur. Donnez un calcul à un champ dans l'onglet Calculer de ses propriétés, et il apparaîtra ici.
{names} and {last} {names} et {last}
@@ -572,6 +578,7 @@
Grayscale Images Images en niveaux de gris
Monochrome Images Images monochromes
CCITT Group 4 CCITT groupe 4
JBIG2 (lossless) JBIG2 (sans perte)
Bicubic downsampling to Sous-échantillonnage bicubique à
for images above pour les images au-dessus de
Compression Compression
@@ -2415,6 +2422,11 @@ animation effects Off Désactivés
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} est calculé avant {fields}, qu'il lit.|{field} est calculé avant {fields}, qu'il lit.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} autre champ est calculé trop tôt.|{n} autres champs sont calculés trop tôt.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manque dans l'ordre enregistré dans le PDF, donc d'autres lecteurs PDF risquent de ne pas le calculer.|{fields} manquent dans l'ordre enregistré dans le PDF, donc d'autres lecteurs PDF risquent de ne pas les calculer.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formule lit un champ absent du formulaire|{n} formules lisent des champs absents du formulaire
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lit {fields}, absent du formulaire.|{field} lit {fields}, absents du formulaire.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} autre formule lit un champ manquant.|{n} autres formules lisent des champs manquants.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} construit certains noms de champs à l'exécution : ces lectures ne sont pas vérifiées.|{fields} construisent certains noms de champs à l'exécution : ces lectures ne sont pas vérifiées.
@plural {fields} is missing|{fields} are missing {fields} est absent|{fields} sont absents
Scan Numériser
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Signez une feuille de papier blanc et prenez-la en photo sous une lumière vive et uniforme (utilisez le flash). Choisissez la photo ou déposez-la ici : PdfCraft trouve la signature et retire le papier.
Choose photo… Choisir une photo…
@@ -2836,3 +2848,4 @@ certificate Version Version
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Options de cette réponse
Export value Valeur d’exportation
Can't render this file because it contains an unexpected character in line 6 and column 39.
+7
View File
@@ -546,6 +546,7 @@
Grayscale Images ग्रेस्केल छवियाँ
Monochrome Images मोनोक्रोम छवियाँ
CCITT Group 4 CCITT समूह 4
JBIG2 (lossless) JBIG2 (हानिरहित)
Bicubic downsampling to बाइक्यूबिक डाउनसैंपलिंग तक
for images above इससे बड़ी छवियों के लिए
Compression संपीड़न
@@ -2731,6 +2732,12 @@ theme Appearance स्वरूप
Links on page Links on page
Loop Loop
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads.
Moves only what must move Moves only what must move
No field reads it No field reads it
Can't render this file because it contains an unexpected character in line 875 and column 12.
+13
View File
@@ -512,6 +512,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Ezeket olvassa: {names}, amelyek visszaolvassák. Hurkot semmilyen sorrend nem javít meg: módosítsd az egyik képletet.
Not in order Nincs a sorrendben
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Hiányzik a PDF-ben mentett számítási sorrendből: más PDF-megjelenítők talán nem számítják ki.
Reads {name}, which the form doesn't have, so it gets no value there. Ezt olvassa: {name}, amely nincs az űrlapon, így onnan nem kap értéket.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. A szkriptje olyan mezőket is olvas, amelyek nevét futás közben állítja össze, így ezek az olvasások nem ellenőrizhetők.
Missing field Hiányzó mező
Unchecked reads Ellenőrizetlen olvasások
{field} reads fields named as it runs {field} futás közben elnevezett mezőket olvas
Some names are built as it runs Néhány név futás közben áll össze
Back to Prepare a form Vissza az Űrlap előkészítéséhez
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ennek az űrlapnak még egyik mezője sem számítja ki az értékét. Adj egy mezőnek számítást a tulajdonságai Számítás lapján, és itt megjelenik.
{names} and {last} {names} és {last}
@@ -570,6 +576,7 @@
Grayscale Images Szürkeárnyalatos képek
Monochrome Images Monokróm képek
CCITT Group 4 CCITT 4. csoport
JBIG2 (lossless) JBIG2 (veszteségmentes)
Bicubic downsampling to Kettős köbös újramintázás erre
for images above az ennél nagyobb képeknél
Compression Tömörítés
@@ -2414,6 +2421,11 @@ animation effects Off Ki
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} előbb számítódik, mint {fields}, amelyet olvas.|{field} előbb számítódik, mint {fields}, amelyeket olvas.
@plural {n} more field calculates too early.|{n} more fields calculate too early. További {n} mező túl korán számítódik.|További {n} mező túl korán számítódik.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} hiányzik a PDF-ben mentett sorrendből, ezért más PDF-megjelenítők esetleg nem számítják ki.|{fields} hiányoznak a PDF-ben mentett sorrendből, ezért más PDF-megjelenítők esetleg nem számítják ki őket.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} képlet olyan mezőt olvas, amely nincs az űrlapon|{n} képlet olyan mezőket olvas, amelyek nincsenek az űrlapon
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} ezt olvassa: {fields}, amely nincs az űrlapon.|{field} ezeket olvassa: {fields}, amelyek nincsenek az űrlapon.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. További {n} képlet hiányzó mezőt olvas.|További {n} képlet hiányzó mezőket olvas.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} futás közben állít össze néhány mezőnevet, így ezek az olvasások nincsenek ellenőrizve.|{fields} futás közben állítanak össze néhány mezőnevet, így ezek az olvasások nincsenek ellenőrizve.
@plural {fields} is missing|{fields} are missing {fields} hiányzik|{fields} hiányoznak
Scan Beolvasás
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Írja alá a nevét egy fehér papírlapon, és fényképezze le erős, egyenletes fényben (vakuval). Válassza ki a fényképet, vagy húzza ide: a PdfCraft megkeresi az aláírást, és eltávolítja a papírt.
Choose photo… Fénykép kiválasztása…
@@ -2569,3 +2581,4 @@ animation effects Off Ki
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply A válasz beállításai
Export value Exportérték
Can't render this file because it contains an unexpected character in line 7 and column 44.
+7
View File
@@ -2065,6 +2065,7 @@ comment menu Edit Edit
Grayscale Images Gambar Skala Abu-abu
Monochrome Images Gambar Monokrom
CCITT Group 4 CCITT Grup 4
JBIG2 (lossless) JBIG2 (tanpa kehilangan)
Green Hijau
Green on black Hijau di atas hitam
H / Space (hold) H / Space (tahan)
@@ -2732,6 +2733,12 @@ theme Appearance Tampilan
Links on page Links on page
Loop Loop
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads.
Moves only what must move Moves only what must move
No field reads it No field reads it
Can't render this file because it contains an unexpected character in line 483 and column 12.
+13
View File
@@ -538,6 +538,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Legge {names}, che a loro volta lo leggono. Nessun ordine può correggere un ciclo: modifica una delle formule.
Not in order Fuori ordine
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manca nell'ordine di calcolo salvato nel PDF: altri visualizzatori PDF potrebbero non calcolarlo.
Reads {name}, which the form doesn't have, so it gets no value there. Legge {name}, che non esiste nel modulo, quindi da lì non riceve alcun valore.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Il suo script legge anche campi i cui nomi costruisce durante l'esecuzione, quindi queste letture non possono essere verificate.
Missing field Campo mancante
Unchecked reads Letture non verificate
{field} reads fields named as it runs {field} legge campi nominati durante l'esecuzione
Some names are built as it runs Alcuni nomi vengono costruiti durante l'esecuzione
Back to Prepare a form Torna a Prepara un modulo
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nessun campo di questo modulo calcola ancora il proprio valore. Assegna un calcolo a un campo nella scheda Calcolo delle sue proprietà e comparirà qui.
{names} and {last} {names} e {last}
@@ -596,6 +602,7 @@
Grayscale Images Immagini in scala di grigi
Monochrome Images Immagini monocromatiche
CCITT Group 4 CCITT gruppo 4
JBIG2 (lossless) JBIG2 (senza perdita)
Bicubic downsampling to Sottocampionamento bicubico a
for images above per immagini oltre
Compression Compressione
@@ -2424,6 +2431,11 @@ animation effects Off Disattivati
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} viene calcolato prima di {fields}, che legge.|{field} viene calcolato prima di {fields}, che legge.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Ancora {n} campo viene calcolato troppo presto.|Altri {n} campi vengono calcolati troppo presto.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manca nell'ordine salvato nel PDF, quindi altri visualizzatori PDF potrebbero non calcolarlo.|{fields} mancano nell'ordine salvato nel PDF, quindi altri visualizzatori PDF potrebbero non calcolarli.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula legge un campo che non esiste nel modulo|{n} formule leggono campi che non esistono nel modulo
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} legge {fields}, che non esiste nel modulo.|{field} legge {fields}, che non esistono nel modulo.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ancora {n} formula legge un campo mancante.|Altre {n} formule leggono campi mancanti.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} costruisce alcuni nomi di campo durante l'esecuzione, quindi queste letture non vengono verificate.|{fields} costruiscono alcuni nomi di campo durante l'esecuzione, quindi queste letture non vengono verificate.
@plural {fields} is missing|{fields} are missing {fields} manca|{fields} mancano
Scan Scansiona
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma un foglio di carta bianca e fotografalo con una luce intensa e uniforme (usa il flash). Scegli la foto o trascinala qui: PdfCraft trova la firma e rimuove la carta.
Choose photo… Scegli foto…
@@ -2802,3 +2814,4 @@ certificate SHA-256 digest Impronta SHA-256
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opzioni per questa risposta
Export value Valore di esportazione
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -541,6 +541,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} を読み取り、それらがこのフィールドを読み返します。循環はどの順序でも直せません。いずれかの数式を変更してください。
Not in order 順序にない
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF に保存された計算順序にありません。ほかの PDF ビューアーでは計算されない可能性があります。
Reads {name}, which the form doesn't have, so it gets no value there. フォームにない {name} を読み取るため、そこからは値を得られません。
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. スクリプトが実行中に名前を組み立てるフィールドも読み取るため、それらの読み取りは確認できません。
Missing field フィールドがない
Unchecked reads 未確認の読み取り
{field} reads fields named as it runs {field} は実行中に名前が決まるフィールドを読み取ります
Some names are built as it runs 一部の名前は実行中に組み立てられます
Back to Prepare a form 「フォームを作成」に戻る
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. このフォームには値を計算するフィールドがまだありません。フィールドのプロパティの[計算]タブで計算を設定すると、ここに表示されます。
{names} and {last} {names}、{last}
@@ -599,6 +605,7 @@
Grayscale Images グレースケール画像
Monochrome Images 白黒画像
CCITT Group 4 CCITT グループ 4
JBIG2 (lossless) JBIG2(可逆)
Bicubic downsampling to バイキュービック法でダウンサンプル:
for images above 対象画像の解像度:
Compression 圧縮
@@ -2415,6 +2422,11 @@ animation effects Off オフ
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} は、読み取る {fields} より先に計算されます。
@plural {n} more field calculates too early.|{n} more fields calculate too early. さらに {n} 個のフィールドが早すぎます。
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} は PDF に保存された順序にないため、他の PDF ビューアーでは計算されない場合があります。
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 個の数式がフォームにないフィールドを読み取ります
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} はフォームにない {fields} を読み取ります。
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. ほかに {n} 個の数式が存在しないフィールドを読み取ります。
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} は一部のフィールド名を実行中に組み立てるため、それらの読み取りは確認されません。
@plural {fields} is missing|{fields} are missing {fields} がありません
Scan スキャン
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 白い紙に署名し、明るく均一な光の下で写真を撮ってください(フラッシュを使用)。写真を選択するか、ここにドロップしてください。PdfCraft が署名を見つけ、紙の部分を取り除きます。
Choose photo… 写真を選択…
@@ -2570,3 +2582,4 @@ animation effects Off オフ
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply この返信のオプション
Export value 書き出し値
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -501,6 +501,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} өрістерін оқиды, олар оны қайта оқиды. Ешбір рет циклды түзете алмайды: формулалардың бірін өзгертіңіз.
Not in order Ретте жоқ
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF ішінде сақталған есептеу ретінде жоқ: басқа PDF қарау құралдары оны есептемеуі мүмкін.
Reads {name}, which the form doesn't have, so it gets no value there. {name} өрісін оқиды, бірақ ол пішінде жоқ, сондықтан одан мән алмайды.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Оның скрипті атауларын орындалу кезінде құрастыратын өрістерді де оқиды, сондықтан бұл оқуларды тексеру мүмкін емес.
Missing field Өріс жоқ
Unchecked reads Тексерілмеген оқулар
{field} reads fields named as it runs {field} орындалу кезінде аталатын өрістерді оқиды
Some names are built as it runs Кейбір атаулар орындалу кезінде құрастырылады
Back to Prepare a form Форманы дайындауға оралу
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Бұл формада мәнін есептейтін өріс әлі жоқ. Өріс сипаттарының «Есептеу» қойындысында оған есептеу беріңіз, сонда ол осында көрсетіледі.
{names} and {last} {names} және {last}
@@ -559,6 +565,7 @@
Grayscale Images Сұр реңкті кескіндер
Monochrome Images Монохромды кескіндер
CCITT Group 4 CCITT 4-топ
JBIG2 (lossless) JBIG2 (шығынсыз)
Bicubic downsampling to Бикубтық төмендету
for images above мұнан асатын кескіндер үшін
Compression Сығымдау
@@ -2402,6 +2409,11 @@ animation effects Off Өшірулі
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} өзі оқитын {fields} өрісінен бұрын есептеледі.|{field} өзі оқитын {fields} өрістерінен бұрын есептеледі.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Тағы {n} өріс тым ерте есептеледі.|Тағы {n} өріс тым ерте есептеледі.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} PDF-те сақталған ретте жоқ, сондықтан басқа PDF қаралымдары оны есептемеуі мүмкін.|{fields} PDF-те сақталған ретте жоқ, сондықтан басқа PDF қаралымдары оларды есептемеуі мүмкін.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула пішінде жоқ өрісті оқиды|{n} формула пішінде жоқ өрістерді оқиды
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} пішінде жоқ {fields} өрісін оқиды.|{field} пішінде жоқ {fields} өрістерін оқиды.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Тағы {n} формула жоқ өрісті оқиды.|Тағы {n} формула жоқ өрістерді оқиды.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} кейбір өріс атауларын орындалу кезінде құрастырады, сондықтан бұл оқулар тексерілмейді.|{fields} кейбір өріс атауларын орындалу кезінде құрастырады, сондықтан бұл оқулар тексерілмейді.
@plural {fields} is missing|{fields} are missing {fields} жоқ|{fields} жоқ
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}|{n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}
Scan Сканерлеу
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды.
@@ -2568,3 +2580,4 @@ animation effects Off Өшірулі
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Осы жауаптың параметрлері
Export value Экспорт мәні
Can't render this file because it contains an unexpected character in line 886 and column 9.
+7
View File
@@ -546,6 +546,7 @@
Grayscale Images 회색조 이미지
Monochrome Images 흑백 이미지
CCITT Group 4 CCITT 그룹 4
JBIG2 (lossless) JBIG2(무손실)
Bicubic downsampling to 다음 값으로 바이큐빅 다운샘플링
for images above 다음 값을 초과하는 이미지에 적용
Compression 압축
@@ -2731,6 +2732,12 @@ theme Appearance 모양
Links on page Links on page
Loop Loop
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads.
Moves only what must move Moves only what must move
No field reads it No field reads it
Can't render this file because it contains an unexpected character in line 875 and column 12.
+8
View File
@@ -567,6 +567,7 @@
Grayscale Images ຮູບພາບລະດັບສີເທົາ
Monochrome Images ຮູບພາບຂາວດຳ
CCITT Group 4 CCITT ກຸ່ມ 4
JBIG2 (lossless) JBIG2 (ບໍ່ສູນເສຍ)
Bicubic downsampling to ຫຼຸດຄວາມລະອຽດແບບ Bicubic ລົງເປັນ:
for images above ສຳລັບຮູບພາບທີ່ເກີນ:
Compression ການບີບອັດ
@@ -2478,3 +2479,10 @@ animation effects Off ປິດ
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply ຕົວເລືອກຂອງການຕອບກັບນີ້
# Untranslated: English text until a translator fills these in.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
Can't render this file because it contains an unexpected character in line 6 and column 32.
+13
View File
@@ -701,6 +701,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Skaito {names}, kurie savo ruožtu skaito jį. Jokia tvarka neištaisys ciklo: pakeiskite vieną iš formulių.
Not in order Ne tvarkoje
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Trūksta PDF faile įrašytoje skaičiavimo tvarkoje: kitos PDF peržiūros programos gali jo neskaičiuoti.
Reads {name}, which the form doesn't have, so it gets no value there. Skaito {name}, kurio formoje nėra, todėl iš ten negauna jokios reikšmės.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jo scenarijus taip pat skaito laukus, kurių pavadinimus sudaro vykdymo metu, todėl šių skaitymų patikrinti negalima.
Missing field Trūksta lauko
Unchecked reads Nepatikrinti skaitymai
{field} reads fields named as it runs {field} skaito laukus, pavadinamus vykdymo metu
Some names are built as it runs Kai kurie pavadinimai sudaromi vykdymo metu
Back to Prepare a form Grįžti į „Rengti formą“
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nė vienas šios formos laukas dar neskaičiuoja savo reikšmės. Nustatykite laukui skaičiavimą jo savybių kortelėje „Skaičiavimas“, ir jis bus rodomas čia.
{names} and {last} {names} ir {last}
@@ -2133,6 +2139,7 @@ comment menu Edit Redaguoti
Grayscale Images Pilkų atspalvių paveikslėliai
Monochrome Images Vienspalviai paveikslėliai
CCITT Group 4 CCITT 4 grupė
JBIG2 (lossless) JBIG2 (be nuostolių)
Green Žalia
Green on black Žalia juodame fone
H / Space (hold) H / tarpo klavišas (laikyti)
@@ -2678,6 +2685,11 @@ certificate SHA-256 digest SHA-256 santrauka
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} apskaičiuojamas anksčiau už {fields}, kurį skaito.|{field} apskaičiuojamas anksčiau už {fields}, kuriuos skaito.|{field} apskaičiuojamas anksčiau už {fields}, kuriuos skaito.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Dar {n} laukas apskaičiuojamas per anksti.|Dar {n} laukai apskaičiuojami per anksti.|Dar {n} laukų apskaičiuojama per anksti.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jo neapskaičiuoti.|{fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jų neapskaičiuoti.|{fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jų neapskaičiuoti.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formulė skaito lauką, kurio formoje nėra|{n} formulės skaito laukus, kurių formoje nėra|{n} formulių skaito laukus, kurių formoje nėra
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} skaito {fields}, kurio formoje nėra.|{field} skaito {fields}, kurių formoje nėra.|{field} skaito {fields}, kurių formoje nėra.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Dar {n} formulė skaito trūkstamą lauką.|Dar {n} formulės skaito trūkstamus laukus.|Dar {n} formulių skaito trūkstamus laukus.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami.|{fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami.|{fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami.
@plural {fields} is missing|{fields} are missing Trūksta {fields}|Trūksta {fields}|Trūksta {fields}
key Ctrl Ctrl
key Shift Shift
key Delete Delete
@@ -2831,3 +2843,4 @@ theme Appearance Išvaizda
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Šio atsakymo parinktys
Export value Eksporto reikšmė
Can't render this file because it contains an unexpected character in line 406 and column 9.
+13
View File
@@ -2070,6 +2070,7 @@
Grayscale Images Gråtonebilder
Monochrome Images Svart-hvitt-bilder
CCITT Group 4 CCITT gruppe 4
JBIG2 (lossless) JBIG2 (tapsfri)
Green Grønn
Green on black Grønn på svart
H / Space (hold) H / Mellomrom (hold)
@@ -2746,3 +2747,15 @@ key Space Mellomrom
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Alternativer for dette svaret
# Untranslated: English text until a translator fills these in.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
Can't render this file because it contains an unexpected character in line 8 and column 16.
+13
View File
@@ -536,6 +536,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Odczytuje {names}, które z kolei odczytują je. Żadna kolejność nie naprawi pętli: zmień jedną z formuł.
Not in order Poza kolejnością
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Brakuje go w kolejności obliczeń zapisanej w pliku PDF: inne przeglądarki PDF mogą go nie obliczać.
Reads {name}, which the form doesn't have, so it gets no value there. Odczytuje {name}, którego nie ma w formularzu, więc nie dostaje stamtąd żadnej wartości.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jego skrypt odczytuje też pola, których nazwy tworzy w trakcie działania, więc tych odczytów nie da się sprawdzić.
Missing field Brak pola
Unchecked reads Niesprawdzone odczyty
{field} reads fields named as it runs {field} odczytuje pola nazywane w trakcie działania
Some names are built as it runs Niektóre nazwy powstają w trakcie działania
Back to Prepare a form Wróć do „Przygotuj formularz”
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Żadne pole tego formularza nie oblicza jeszcze swojej wartości. Nadaj polu obliczenie na karcie Obliczenia w jego właściwościach, a pojawi się tutaj.
{names} and {last} {names} i {last}
@@ -594,6 +600,7 @@
Grayscale Images Obrazy w skali szarości
Monochrome Images Obrazy monochromatyczne
CCITT Group 4 CCITT grupa 4
JBIG2 (lossless) JBIG2 (bezstratna)
Bicubic downsampling to Zmniejsz rozdzielczość metodą dwusześcienną do
for images above dla obrazów powyżej
Compression Kompresja
@@ -2383,6 +2390,11 @@ animation effects Off Wyłączone
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} jest obliczane przed {fields}, które odczytuje.|{field} jest obliczane przed {fields}, które odczytuje.|{field} jest obliczane przed {fields}, które odczytuje.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Jeszcze {n} pole jest obliczane za wcześnie.|Jeszcze {n} pola są obliczane za wcześnie.|Jeszcze {n} pól jest obliczanych za wcześnie.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą go nie obliczać.|W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą ich nie obliczać.|W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą ich nie obliczać.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formuła odczytuje pole, którego nie ma w formularzu|{n} formuły odczytują pola, których nie ma w formularzu|{n} formuł odczytuje pola, których nie ma w formularzu
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} odczytuje {fields}, którego nie ma w formularzu.|{field} odczytuje {fields}, których nie ma w formularzu.|{field} odczytuje {fields}, których nie ma w formularzu.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Jeszcze {n} formuła odczytuje brakujące pole.|Jeszcze {n} formuły odczytują brakujące pola.|Jeszcze {n} formuł odczytuje brakujące pola.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} tworzy część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane.|{fields} tworzą część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane.|{fields} tworzą część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane.
@plural {fields} is missing|{fields} are missing Brak {fields}|Brak {fields}|Brak {fields}
Scan Skanuj
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpisz się na białej kartce i zrób jej zdjęcie przy jasnym, równomiernym świetle (użyj lampy błyskowej). Wybierz zdjęcie lub upuść je tutaj: PdfCraft znajdzie podpis i usunie papier.
Choose photo… Wybierz zdjęcie…
@@ -2666,3 +2678,4 @@ animation effects Off Wyłączone
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opcje tej odpowiedzi
Export value Wartość eksportowa
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -159,6 +159,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lê {names}, que o leem de volta. Nenhuma ordem resolve um ciclo: altere uma das fórmulas.
Not in order Fora da ordem
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Ausente da ordem de cálculo salva no PDF: outros leitores de PDF podem não calculá-lo.
Reads {name}, which the form doesn't have, so it gets no value there. Lê {name}, que não existe no formulário, então não recebe nenhum valor dali.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Seu script também lê campos cujos nomes monta durante a execução, então essas leituras não podem ser verificadas.
Missing field Campo ausente
Unchecked reads Leituras não verificadas
{field} reads fields named as it runs {field} lê campos nomeados durante a execução
Some names are built as it runs Alguns nomes são montados durante a execução
Back to Prepare a form Voltar para Preparar um formulário
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nenhum campo deste formulário calcula seu valor ainda. Dê um cálculo a um campo na aba Calcular das propriedades dele, e ele aparecerá aqui.
{names} and {last} {names} e {last}
@@ -218,6 +224,7 @@
Grayscale Images Imagens em escala de cinza
Monochrome Images Imagens monocromáticas
CCITT Group 4 CCITT grupo 4
JBIG2 (lossless) JBIG2 (sem perdas)
Bicubic downsampling to Redução bicúbica de amostragem para
for images above para imagens acima de
Compression Compactação
@@ -2429,6 +2436,11 @@ animation effects Off Desativados
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} é calculado antes de {fields}, que ele lê.|{field} é calculado antes de {fields}, que ele lê.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Mais {n} campo é calculado cedo demais.|Mais {n} campos são calculados cedo demais.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} não está na ordem salva no PDF, então outros visualizadores de PDF podem não calculá-lo.|{fields} não estão na ordem salva no PDF, então outros visualizadores de PDF podem não calculá-los.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} fórmula lê um campo que não existe no formulário|{n} fórmulas leem campos que não existem no formulário
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lê {fields}, que não existe no formulário.|{field} lê {fields}, que não existem no formulário.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Mais {n} fórmula lê um campo ausente.|Mais {n} fórmulas leem campos ausentes.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} monta alguns nomes de campo durante a execução, então essas leituras não são verificadas.|{fields} montam alguns nomes de campo durante a execução, então essas leituras não são verificadas.
@plural {fields} is missing|{fields} are missing {fields} não existe|{fields} não existem
Scan Digitalizar
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Assine uma folha de papel branco e tire uma foto dela com luz forte e uniforme (use o flash). Escolha a foto ou solte-a aqui: o PdfCraft encontra a assinatura e remove o papel.
Choose photo… Escolher foto…
@@ -2855,3 +2867,4 @@ theme Appearance Aparência
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opções desta resposta
Export value Valor de exportação
Can't render this file because it contains an unexpected character in line 9 and column 16.
+12
View File
@@ -2076,6 +2076,7 @@ comment menu Edit Editare
Grayscale Images Imagini în tonuri de gri
Monochrome Images Imagini monocrome
CCITT Group 4 CCITT grupa 4
JBIG2 (lossless) JBIG2 (fără pierderi)
Green Verde
Green on black Verde pe negru
H / Space (hold) H / Spațiu (ținut apăsat)
@@ -2733,6 +2734,12 @@ theme Appearance Aspect
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Citește {names}, care îl citesc la rândul lor. Nicio ordine nu poate rezolva o buclă: modificați una dintre formule.
Not in order În afara ordinii
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Lipsește din ordinea de calcul salvată în PDF: alte aplicații de vizualizare PDF s-ar putea să nu-l calculeze.
Reads {name}, which the form doesn't have, so it gets no value there. Citește {name}, care nu există în formular, deci nu primește nicio valoare de acolo.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Scriptul său citește și câmpuri ale căror nume le construiește în timpul rulării, deci aceste citiri nu pot fi verificate.
Missing field Câmp lipsă
Unchecked reads Citiri neverificate
{field} reads fields named as it runs {field} citește câmpuri numite în timpul rulării
Some names are built as it runs Unele nume sunt construite în timpul rulării
Back to Prepare a form Înapoi la Pregătire formular
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Niciun câmp din acest formular nu își calculează încă valoarea. Adăugați un calcul unui câmp în fila Calcul din proprietățile lui și va apărea aici.
{names} and {last} {names} și {last}
@@ -2760,6 +2767,11 @@ theme Appearance Aspect
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se calculează înaintea câmpului {fields}, pe care îl citește.|{field} se calculează înaintea câmpurilor {fields}, pe care le citește.|{field} se calculează înaintea câmpurilor {fields}, pe care le citește.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Încă {n} câmp se calculează prea devreme.|Încă {n} câmpuri se calculează prea devreme.|Încă {n} de câmpuri se calculează prea devreme.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} lipsește din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu-l calculeze.|{fields} lipsesc din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu le calculeze.|{fields} lipsesc din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu le calculeze.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formulă citește un câmp care nu există în formular|{n} formule citesc câmpuri care nu există în formular|{n} de formule citesc câmpuri care nu există în formular
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} citește {fields}, care nu există în formular.|{field} citește {fields}, care nu există în formular.|{field} citește {fields}, care nu există în formular.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Încă {n} formulă citește un câmp lipsă.|Încă {n} formule citesc câmpuri lipsă.|Încă {n} de formule citesc câmpuri lipsă.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} construiește unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate.|{fields} construiesc unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate.|{fields} construiesc unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate.
@plural {fields} is missing|{fields} are missing {fields} lipsește|{fields} lipsesc|{fields} lipsesc
Bulleted list Listă cu marcatori
Numbered list Listă numerotată
Alignment Aliniere
Can't render this file because it contains an unexpected character in line 3 and column 110.
+13
View File
@@ -534,6 +534,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Читает {names}, которые в свою очередь читают его. Никакой порядок не исправит цикл: измените одну из формул.
Not in order Вне порядка
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Отсутствует в порядке вычислений, сохранённом в PDF: другие программы просмотра PDF могут его не вычислять.
Reads {name}, which the form doesn't have, so it gets no value there. Читает {name}, которого нет в форме, поэтому не получает оттуда значения.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Его скрипт также читает поля, имена которых составляет во время выполнения, поэтому эти чтения нельзя проверить.
Missing field Нет поля
Unchecked reads Непроверенные чтения
{field} reads fields named as it runs {field} читает поля, имена которых задаются при выполнении
Some names are built as it runs Некоторые имена составляются при выполнении
Back to Prepare a form Назад к «Подготовить форму»
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ни одно поле этой формы пока не вычисляет своё значение. Задайте полю вычисление на вкладке «Вычислить» его свойств, и оно появится здесь.
{names} and {last} {names} и {last}
@@ -592,6 +598,7 @@
Grayscale Images Изображения в оттенках серого
Monochrome Images Монохромные изображения
CCITT Group 4 CCITT группа 4
JBIG2 (lossless) JBIG2 (без потерь)
Bicubic downsampling to Бикубическое уменьшение до
for images above для изображений выше
Compression Сжатие
@@ -2328,6 +2335,11 @@ animation effects Off Выкл
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} вычисляется раньше {fields}, которое оно читает.|{field} вычисляется раньше {fields}, которые оно читает.|{field} вычисляется раньше {fields}, которые оно читает.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Ещё {n} поле вычисляется слишком рано.|Ещё {n} поля вычисляются слишком рано.|Ещё {n} полей вычисляются слишком рано.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут его не вычислить.|{fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут их не вычислить.|{fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут их не вычислить.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула читает поле, которого нет в форме|{n} формулы читают поля, которых нет в форме|{n} формул читают поля, которых нет в форме
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} читает {fields}, которого нет в форме.|{field} читает {fields}, которых нет в форме.|{field} читает {fields}, которых нет в форме.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ещё {n} формула читает отсутствующее поле.|Ещё {n} формулы читают отсутствующие поля.|Ещё {n} формул читают отсутствующие поля.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} составляет некоторые имена полей при выполнении, поэтому эти чтения не проверяются.|{fields} составляют некоторые имена полей при выполнении, поэтому эти чтения не проверяются.|{fields} составляют некоторые имена полей при выполнении, поэтому эти чтения не проверяются.
@plural {fields} is missing|{fields} are missing {fields} отсутствует|{fields} отсутствуют|{fields} отсутствуют
Scan Сканировать
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Распишитесь на листе белой бумаги и сфотографируйте его при ярком равномерном освещении (со вспышкой). Выберите фото или перетащите его сюда: PdfCraft найдёт подпись и уберёт бумагу.
Choose photo… Выбрать фото…
@@ -2483,3 +2495,4 @@ animation effects Off Выкл
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Параметры этого ответа
Export value Значение экспорта
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13
View File
@@ -893,6 +893,7 @@
Grayscale Images Sivinske slike
Monochrome Images Enobarvne slike
CCITT Group 4 CCITT skupina 4
JBIG2 (lossless) JBIG2 (brez izgub)
Green Zelena
H / Space (hold) H / preslednica (drži)
HTML web page Spletna stran HTML
@@ -1029,6 +1030,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Bere {names}, ki ga berejo nazaj. Noben vrstni red ne popravi zanke: spremenite eno od formul.
Not in order Ni v vrstnem redu
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manjka v vrstnem redu izračunov, shranjenem v PDF-ju: drugi pregledovalniki PDF ga morda ne izračunajo.
Reads {name}, which the form doesn't have, so it gets no value there. Bere {name}, ki ga v obrazcu ni, zato od tam ne dobi vrednosti.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Njegov skript bere tudi polja, katerih imena sestavi med izvajanjem, zato teh branj ni mogoče preveriti.
Missing field Manjka polje
Unchecked reads Nepreverjena branja
{field} reads fields named as it runs {field} bere polja, poimenovana med izvajanjem
Some names are built as it runs Nekatera imena se sestavijo med izvajanjem
Back to Prepare a form Nazaj na Pripravi obrazec
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nobeno polje tega obrazca še ne izračuna svoje vrednosti. Polju dodelite izračun na zavihku Izračunaj v njegovih lastnostih in prikazalo se bo tukaj.
{names} and {last} {names} in {last}
@@ -2419,6 +2426,11 @@ animation effects Off Izklopljeni
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se izračuna pred {fields}, ki ga bere.|{field} se izračuna pred {fields}, ki ju bere.|{field} se izračuna pred {fields}, ki jih bere.|{field} se izračuna pred {fields}, ki jih bere.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Še {n} polje se izračuna prezgodaj.|Še {n} polji se izračunata prezgodaj.|Še {n} polja se izračunajo prezgodaj.|Še {n} polj se izračuna prezgodaj.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manjka v vrstnem redu, shranjenem v PDF-ju, zato ga drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkata v vrstnem redu, shranjenem v PDF-ju, zato ju drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkajo v vrstnem redu, shranjenem v PDF-ju, zato jih drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkajo v vrstnem redu, shranjenem v PDF-ju, zato jih drugi pregledovalniki PDF morda ne bodo izračunali.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula bere polje, ki ga v obrazcu ni|{n} formuli bereta polja, ki ju v obrazcu ni|{n} formule berejo polja, ki jih v obrazcu ni|{n} formul bere polja, ki jih v obrazcu ni
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} bere {fields}, ki ga v obrazcu ni.|{field} bere {fields}, ki ju v obrazcu ni.|{field} bere {fields}, ki jih v obrazcu ni.|{field} bere {fields}, ki jih v obrazcu ni.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Še {n} formula bere manjkajoče polje.|Še {n} formuli bereta manjkajoča polja.|Še {n} formule berejo manjkajoča polja.|Še {n} formul bere manjkajoča polja.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} nekatera imena polj sestavi med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavita med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavijo med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavijo med izvajanjem, zato ta branja niso preverjena.
@plural {fields} is missing|{fields} are missing {fields} manjka|{fields} manjkata|{fields} manjkajo|{fields} manjka
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ime polja se ujema z imeni iz prejšnjih datotek in bo preimenovano: {list}|{n} imeni polj se ujemata z imeni iz prejšnjih datotek in bosta preimenovani: {list}|{n} imena polj se ujemajo z imeni iz prejšnjih datotek in bodo preimenovana: {list}|{n} imen polj se ujema z imeni iz prejšnjih datotek in bo preimenovanih: {list}
Scan Skeniraj
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir.
@@ -2575,3 +2587,4 @@ animation effects Off Izklopljeni
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Možnosti tega odgovora
Export value Izvozna vrednost
Can't render this file because it contains an unexpected character in line 9 and column 16.
+12
View File
@@ -882,6 +882,7 @@
Grayscale Images Imazhe në nuanca gri
Monochrome Images Imazhe bardhezi
CCITT Group 4 CCITT grupi 4
JBIG2 (lossless) JBIG2 (pa humbje)
Green Jeshile
H / Space (hold) H / Hapësira (mbaje shtypur)
HTML web page Faqe interneti HTML
@@ -2419,6 +2420,17 @@ animation effects Off Joaktiv
Mark as artifact Mark as artifact
Merge image fragments Merge image fragments
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
Mobile Mobile
Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads.
Moves only what must move Moves only what must move
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13
View File
@@ -518,6 +518,12 @@ comment menu Edit సవరించు
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names}ను చదువుతుంది, అవి దీన్ని తిరిగి చదువుతాయి. ఏ క్రమమూ లూప్‌ను సరిచేయదు: సూత్రాలలో ఒకదాన్ని మార్చండి.
Not in order క్రమంలో లేదు
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDFలో సేవ్ చేసిన గణన క్రమంలో లేదు: ఇతర PDF వీక్షకులు దీన్ని లెక్కించకపోవచ్చు.
Reads {name}, which the form doesn't have, so it gets no value there. {name}ను చదువుతుంది, కానీ అది ఫారమ్‌లో లేదు, కాబట్టి దాని నుండి విలువ రాదు.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. దీని స్క్రిప్ట్ నడుస్తున్నప్పుడు పేర్లను కూర్చే ఫీల్డ్‌లను కూడా చదువుతుంది, కాబట్టి ఆ చదువులను తనిఖీ చేయలేము.
Missing field ఫీల్డ్ లేదు
Unchecked reads తనిఖీ చేయని చదువులు
{field} reads fields named as it runs {field} నడుస్తున్నప్పుడు పేరు పెట్టే ఫీల్డ్‌లను చదువుతుంది
Some names are built as it runs కొన్ని పేర్లు నడుస్తున్నప్పుడు కూర్చబడతాయి
Back to Prepare a form ‘ఫారాన్ని సిద్ధం చేయి’కి తిరిగి వెళ్లు
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. ఈ ఫారంలో ఏ ఫీల్డ్ ఇంకా తన విలువను గణించదు. ఫీల్డ్ లక్షణాలలోని గణన ట్యాబ్‌లో దానికి గణన ఇవ్వండి, అది ఇక్కడ కనిపిస్తుంది.
{names} and {last} {names} మరియు {last}
@@ -576,6 +582,7 @@ comment menu Edit సవరించు
Grayscale Images గ్రేస్కేల్ చిత్రాలు
Monochrome Images మోనోక్రోమ్ చిత్రాలు
CCITT Group 4 CCITT గ్రూప్ 4
JBIG2 (lossless) JBIG2 (నష్టరహితం)
Bicubic downsampling to బైక్యూబిక్ డౌన్‌శాంప్లింగ్, లక్ష్యం:
for images above ఈ రిజల్యూషన్ మించిన చిత్రాలకు:
Compression కుదింపు
@@ -2316,6 +2323,11 @@ animation effects Off ఆఫ్
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} తాను చదివే {fields} కంటే ముందు గణించబడుతుంది.|{field} తాను చదివే {fields} కంటే ముందు గణించబడుతుంది.
@plural {n} more field calculates too early.|{n} more fields calculate too early. మరో {n} ఫీల్డ్ చాలా ముందుగా గణించబడుతుంది.|మరో {n} ఫీల్డ్‌లు చాలా ముందుగా గణించబడతాయి.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} PDFలో సేవ్ చేసిన క్రమంలో లేదు, కాబట్టి ఇతర PDF వ్యూయర్‌లు దాన్ని గణించకపోవచ్చు.|{fields} PDFలో సేవ్ చేసిన క్రమంలో లేవు, కాబట్టి ఇతర PDF వ్యూయర్‌లు వాటిని గణించకపోవచ్చు.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} సూత్రం ఫారమ్‌లో లేని ఫీల్డ్‌ను చదువుతుంది|{n} సూత్రాలు ఫారమ్‌లో లేని ఫీల్డ్‌లను చదువుతాయి
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} ఫారమ్‌లో లేని {fields}‌ను చదువుతుంది.|{field} ఫారమ్‌లో లేని {fields}‌ను చదువుతుంది.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. మరో {n} సూత్రం లేని ఫీల్డ్‌ను చదువుతుంది.|మరో {n} సూత్రాలు లేని ఫీల్డ్‌లను చదువుతాయి.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} నడుస్తున్నప్పుడు కొన్ని ఫీల్డ్ పేర్లను కూర్చుతుంది, కాబట్టి ఆ చదువులు తనిఖీ చేయబడవు.|{fields} నడుస్తున్నప్పుడు కొన్ని ఫీల్డ్ పేర్లను కూర్చుతాయి, కాబట్టి ఆ చదువులు తనిఖీ చేయబడవు.
@plural {fields} is missing|{fields} are missing {fields} లేదు|{fields} లేవు
Scan స్కాన్
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. తెల్ల కాగితంపై సంతకం చేసి, ప్రకాశవంతమైన, సమానమైన వెలుతురులో దాని ఫోటో తీయండి (ఫ్లాష్ ఉపయోగించండి). ఫోటోను ఎంచుకోండి లేదా ఇక్కడ వదలండి: PdfCraft సంతకాన్ని కనుగొని కాగితాన్ని తొలగిస్తుంది.
Choose photo… ఫోటో ఎంచుకోండి…
@@ -2479,3 +2491,4 @@ animation effects Off ఆఫ్
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు
Export value ఎగుమతి విలువ
Can't render this file because it contains an unexpected character in line 13 and column 16.
+8
View File
@@ -556,6 +556,7 @@
Grayscale Images รูปภาพระดับสีเทา
Monochrome Images รูปภาพขาวดำ
CCITT Group 4 CCITT กลุ่ม 4
JBIG2 (lossless) JBIG2 (ไม่สูญเสียข้อมูล)
Bicubic downsampling to ลดความละเอียดแบบ Bicubic เป็น
for images above สำหรับรูปภาพที่สูงกว่า
Compression การบีบอัด
@@ -2733,3 +2734,10 @@ theme Appearance ลักษณะที่ปรากฏ
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply ตัวเลือกของการตอบกลับนี้
# Untranslated: English text until a translator fills these in.
Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked.
Missing field Missing field
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
Can't render this file because it contains an unexpected character in line 886 and column 12.
+13
View File
@@ -499,6 +499,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} alanlarını okur, onlar da bu alanı okur. Hiçbir sıra bir döngüyü düzeltemez: formüllerden birini değiştirin.
Not in order Sırada değil
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF'te kayıtlı hesaplama sırasında yok: diğer PDF görüntüleyicileri bunu hesaplamayabilir.
Reads {name}, which the form doesn't have, so it gets no value there. Formda olmayan {name} alanını okur, bu yüzden oradan değer alamaz.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Komut dosyası, adlarını çalışırken oluşturduğu alanları da okur; bu okumalar denetlenemez.
Missing field Eksik alan
Unchecked reads Denetlenmeyen okumalar
{field} reads fields named as it runs {field}, çalışırken adlandırılan alanları okuyor
Some names are built as it runs Bazı adlar çalışırken oluşturuluyor
Back to Prepare a form Form hazırla'ya geri dön
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Bu formdaki hiçbir alan henüz değerini hesaplamıyor. Bir alana özelliklerinin Hesapla sekmesinde bir hesaplama verin, burada görünür.
{names} and {last} {names} ve {last}
@@ -557,6 +563,7 @@
Grayscale Images Gri Tonlamalı Resimler
Monochrome Images Tek Renkli Resimler
CCITT Group 4 CCITT Grup 4
JBIG2 (lossless) JBIG2 (kayıpsız)
Bicubic downsampling to Şu değere bikübik alt örnekleme:
for images above bundan büyük resimler için:
Compression Sıkıştırma
@@ -2400,6 +2407,11 @@ animation effects Off Kapalı
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} önce hesaplanıyor; okuduğu {fields} daha sonra hesaplanıyor.
@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} alan daha çok erken hesaplanıyor.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields}, PDF'te kayıtlı sırada yok; bu yüzden diğer PDF görüntüleyicileri hesaplamayabilir.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formül formda olmayan alanları okuyor
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field}, formda olmayan {fields} alanını okuyor.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} formül daha eksik alanları okuyor.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields}, bazı alan adlarını çalışırken oluşturuyor; bu okumalar denetlenmiyor.
@plural {fields} is missing|{fields} are missing {fields} eksik
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} alan adı önceki dosyalarla çakışıyor ve yeniden adlandırılacak: {list}
Scan Tara
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır.
@@ -2566,3 +2578,4 @@ animation effects Off Kapalı
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Bu yanıtın seçenekleri
Export value Dışa aktarma değeri
Can't render this file because it contains an unexpected character in line 885 and column 9.
+13
View File
@@ -539,6 +539,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Читає {names}, які своєю чергою читають його. Жоден порядок не виправить цикл: змініть одну з формул.
Not in order Поза порядком
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Відсутнє в порядку обчислень, збереженому в PDF: інші переглядачі PDF можуть його не обчислювати.
Reads {name}, which the form doesn't have, so it gets no value there. Читає {name}, якого немає у формі, тому не отримує звідти значення.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Його сценарій також читає поля, імена яких складає під час виконання, тому ці читання неможливо перевірити.
Missing field Немає поля
Unchecked reads Неперевірені читання
{field} reads fields named as it runs {field} читає поля, імена яких задаються під час виконання
Some names are built as it runs Деякі імена складаються під час виконання
Back to Prepare a form Назад до «Підготувати форму»
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Жодне поле цієї форми поки не обчислює своє значення. Задайте полю обчислення на вкладці «Обчислення» його властивостей, і воно з’явиться тут.
{names} and {last} {names} і {last}
@@ -597,6 +603,7 @@
Grayscale Images Зображення у відтінках сірого
Monochrome Images Монохромні зображення
CCITT Group 4 CCITT група 4
JBIG2 (lossless) JBIG2 (без втрат)
Bicubic downsampling to Бікубічне зниження роздільної здатності до
for images above для зображень із роздільною здатністю понад
Compression Стиснення
@@ -2437,6 +2444,11 @@ animation effects Off Вимкнено
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} обчислюється раніше за {fields}, яке воно читає.|{field} обчислюється раніше за {fields}, які воно читає.|{field} обчислюється раніше за {fields}, які воно читає.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Ще {n} поле обчислюється зарано.|Ще {n} поля обчислюються зарано.|Ще {n} полів обчислюються зарано.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть його не обчислити.|{fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть їх не обчислити.|{fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть їх не обчислити.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула читає поле, якого немає у формі|{n} формули читають поля, яких немає у формі|{n} формул читають поля, яких немає у формі
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} читає {fields}, якого немає у формі.|{field} читає {fields}, яких немає у формі.|{field} читає {fields}, яких немає у формі.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ще {n} формула читає відсутнє поле.|Ще {n} формули читають відсутні поля.|Ще {n} формул читають відсутні поля.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} складає деякі імена полів під час виконання, тому ці читання не перевіряються.|{fields} складають деякі імена полів під час виконання, тому ці читання не перевіряються.|{fields} складають деякі імена полів під час виконання, тому ці читання не перевіряються.
@plural {fields} is missing|{fields} are missing Немає {fields}|Немає {fields}|Немає {fields}
Scan Сканувати
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Розпишіться на аркуші білого паперу та сфотографуйте його за яскравого рівномірного освітлення (зі спалахом). Виберіть фото або перетягніть його сюди: PdfCraft знайде підпис і прибере папір.
Choose photo… Вибрати фото…
@@ -2592,3 +2604,4 @@ animation effects Off Вимкнено
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Параметри цієї відповіді
Export value Значення експорту
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -697,6 +697,12 @@ theme Appearance Giao diện
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Đọc {names}, các trường này lại đọc ngược nó. Không thứ tự nào sửa được vòng lặp: hãy đổi một trong các công thức.
Not in order Không có trong thứ tự
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Không có trong thứ tự tính toán lưu trong PDF: các trình xem PDF khác có thể không tính trường này.
Reads {name}, which the form doesn't have, so it gets no value there. Đọc {name}, nhưng biểu mẫu không có trường này, nên không nhận được giá trị nào từ đó.
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Tập lệnh của nó còn đọc các trường có tên được tạo ra khi chạy, nên không thể kiểm tra các lần đọc đó.
Missing field Thiếu trường
Unchecked reads Lần đọc chưa kiểm tra
{field} reads fields named as it runs {field} đọc các trường được đặt tên khi chạy
Some names are built as it runs Một số tên được tạo ra khi chạy
Back to Prepare a form Quay lại Chuẩn bị biểu mẫu
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Chưa có trường nào trong biểu mẫu này tự tính giá trị. Hãy đặt phép tính cho một trường ở thẻ Tính toán trong thuộc tính của nó, trường đó sẽ hiện ở đây.
{names} and {last} {names} và {last}
@@ -2121,6 +2127,7 @@ comment menu Edit Sửa
Grayscale Images Ảnh thang xám
Monochrome Images Ảnh đơn sắc
CCITT Group 4 CCITT nhóm 4
JBIG2 (lossless) JBIG2 (không mất dữ liệu)
Green Xanh lá
Green on black Xanh lá trên nền đen
H / Space (hold) H / Space (giữ)
@@ -2666,6 +2673,11 @@ certificate SHA-256 digest Giá trị băm SHA-256
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} được tính trước {fields} mà nó đọc.
@plural {n} more field calculates too early.|{n} more fields calculate too early. Còn {n} trường khác được tính quá sớm.
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} không có trong thứ tự lưu trong PDF, nên các trình xem PDF khác có thể không tính.
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} công thức đọc trường không có trong biểu mẫu
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} đọc {fields}, nhưng biểu mẫu không có.
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Còn {n} công thức khác đọc trường bị thiếu.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} tạo một số tên trường khi chạy, nên các lần đọc đó không được kiểm tra.
@plural {fields} is missing|{fields} are missing Thiếu {fields}
key Ctrl Ctrl
key Shift Shift
key Delete Delete
@@ -2837,3 +2849,4 @@ key Space Space
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Tùy chọn cho câu trả lời này
Export value Giá trị xuất
Can't render this file because it contains an unexpected character in line 493 and column 12.
+13
View File
@@ -208,6 +208,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. 读取 {names},而它们又读取此字段。任何顺序都无法修正循环:请修改其中一个公式。
Not in order 不在顺序中
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中保存的计算顺序缺少此字段:其他 PDF 查看器可能不会计算它。
Reads {name}, which the form doesn't have, so it gets no value there. 读取表单中不存在的 {name},因此从中得不到值。
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其脚本还会读取运行时才拼出名称的字段,因此无法检查这些读取。
Missing field 缺少字段
Unchecked reads 未检查的读取
{field} reads fields named as it runs {field} 读取运行时才确定名称的字段
Some names are built as it runs 部分名称在运行时才拼出
Back to Prepare a form 返回准备表单
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表单中还没有计算自身值的字段。在字段属性的“计算”选项卡中为字段设置计算后,它会显示在这里。
{names} and {last} {names}和{last}
@@ -267,6 +273,7 @@
Grayscale Images 灰度图像
Monochrome Images 单色图像
CCITT Group 4 CCITT 第 4 组
JBIG2 (lossless) JBIG2(无损)
Bicubic downsampling to 双三次降采样至
for images above (高于此分辨率的图像)
Compression 压缩
@@ -2379,6 +2386,11 @@ animation effects Off 关
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} 在它读取的 {fields} 之前计算。
@plural {n} more field calculates too early.|{n} more fields calculate too early. 另有 {n} 个字段计算过早。
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} 不在 PDF 中保存的顺序里,其他 PDF 查看器可能不会计算。
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 个公式读取了表单中不存在的字段
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} 读取表单中不存在的 {fields}。
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. 另有 {n} 个公式读取了缺失的字段。
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} 在运行时才拼出部分字段名称,因此这些读取不会被检查。
@plural {fields} is missing|{fields} are missing 缺少 {fields}
Scan 扫描
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白纸上签名,并在明亮、均匀的光线下拍照(使用闪光灯)。选择照片或将其拖放到此处:PdfCraft 会找到签名并去除纸张背景。
Choose photo… 选择照片…
@@ -2534,3 +2546,4 @@ animation effects Off 关
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply 此回复的选项
Export value 导出值
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13
View File
@@ -164,6 +164,12 @@
Reads {names}, which read it back. No order can fix a loop: change one of the formulas. 讀取 {names},而它們又讀取此欄位。任何順序都無法修正迴圈:請修改其中一個公式。
Not in order 不在順序中
Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中儲存的計算順序缺少此欄位:其他 PDF 檢視器可能不會計算它。
Reads {name}, which the form doesn't have, so it gets no value there. 讀取表單中不存在的 {name},因此從中得不到值。
Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其指令碼還會讀取執行時才組出名稱的欄位,因此無法檢查這些讀取。
Missing field 缺少欄位
Unchecked reads 未檢查的讀取
{field} reads fields named as it runs {field} 讀取執行時才確定名稱的欄位
Some names are built as it runs 部分名稱在執行時才組出
Back to Prepare a form 返回準備表單
No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表單中還沒有計算自身值的欄位。在欄位內容的「計算」索引標籤中為欄位設定計算後,它會顯示在這裡。
{names} and {last} {names}和{last}
@@ -223,6 +229,7 @@
Grayscale Images 灰階圖片
Monochrome Images 單色圖片
CCITT Group 4 CCITT 第 4 組
JBIG2 (lossless) JBIG2(無損)
Bicubic downsampling to 雙三次降取樣至
for images above (高於此解析度的圖片)
Compression 壓縮
@@ -2278,6 +2285,11 @@ animation effects Off 關
@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} 在它讀取的 {fields} 之前計算。
@plural {n} more field calculates too early.|{n} more fields calculate too early. 另有 {n} 個欄位計算過早。
@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} 不在 PDF 中儲存的順序裡,其他 PDF 檢視器可能不會計算。
@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 個公式讀取了表單中不存在的欄位
@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} 讀取表單中不存在的 {fields}。
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. 另有 {n} 個公式讀取了缺少的欄位。
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} 在執行時才組出部分欄位名稱,因此這些讀取不會被檢查。
@plural {fields} is missing|{fields} are missing 缺少 {fields}
Scan 掃描
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白紙上簽名,並在明亮、均勻的光線下拍照(使用閃光燈)。選擇相片或將其拖放到此處:PdfCraft 會找到簽名並去除紙張背景。
Choose photo… 選擇相片…
@@ -2520,3 +2532,4 @@ animation effects Off 關
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply 此回覆的選項
Export value 匯出值
Can't render this file because it contains an unexpected character in line 9 and column 16.
+22 -2
View File
@@ -256,9 +256,13 @@ fn english(labeled: Labeled<'_>) -> Option<String> {
}
fn translate(english: &'static str) -> String {
// The bundled UI fonts do not all cover the menu's mathematical ellipsis.
// craft_menu's bar writes "⋯" (U+22EF, MIDLINE HORIZONTAL ELLIPSIS) itself, for the menu of
// titles that don't fit: a sentinel to translate, not an English phrase, and the only one of
// its four reserved labels no font this app ships or falls back to has a glyph for.
// MissingGlyphPolicy::Panic (egui 0.37) crashes on it. The ordinary ellipsis every other
// truncated label in this app already uses is in the bundled faces.
if english == "\u{22ef}" {
return "...".to_owned();
return "…".to_owned();
}
tl!(english).to_owned()
}
@@ -662,6 +666,22 @@ mod tests {
bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key()))
}
/// The bar's own overflow-menu sentinel is not rendered as typed: no font this app ships or
/// falls back to has U+22EF, and egui 0.37's `MissingGlyphPolicy::Panic` crashes on it when
/// the menu bar is narrow enough to need it. The editing tests that narrow the window while a
/// paragraph is open reproduce this end to end. The other three reserved labels are plain
/// ASCII, so untranslated is safe for them.
#[test]
fn the_overflow_menu_sentinel_is_not_passed_through_untranslated() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let translated = translate("⋯");
assert_ne!(translated, "⋯", "no installed font has this glyph");
assert_eq!(translated, "…", "the ordinary ellipsis the rest of the app already uses");
for label in ["Menu", "Scroll menu up", "Scroll menu down"] {
assert_eq!(translate(label), label, "plain ASCII reserved labels need no substitution");
}
}
#[test]
fn every_menu_command_is_listed_once_with_its_shortcut() {
let (app, ctx) = app(&["a.pdf"]);
+58 -7
View File
@@ -267,7 +267,9 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
.map(|f| (f.resource, f.face))
.collect();
let shown = ed.font_shown(&sources);
let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown });
// A selection aligns the rows it touches; the caret alone, the whole paragraph.
let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false };
let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }, Some(paragraphs));
let mut changed = out.changed;
match out.font {
Some(FontChoice::KeepSource) => {
@@ -294,6 +296,21 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
return;
}
// Typing added text: the box's formatting, and alignment for the lines a selection touches.
if let Some(i) = app.active_ids().map(|(i, _)| i)
&& let Some(draft) = app.views[i].content.draft.clone()
{
if draft.index.is_none() && app.quick_tool == crate::QuickTool::AddText {
crate::content_ui::hint(ui, t);
}
let mut style = draft.style.clone();
let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true };
added_text_format(ui, t, &mut style, Some(paragraphs));
if style != draft.style {
app.restyle_draft(i, style);
}
return;
}
let image = app.active_ids().and_then(|(i, id)| {
let (page, index) = app.views[i].content.selected?;
let doc = app.session.get(id)?;
@@ -326,34 +343,68 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
match selected {
Some((page, index, text)) => {
let mut style = text.clone();
added_text_format(ui, t, &mut style);
added_text_format(ui, t, &mut style, None);
if style != text {
// The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later.
if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) {
app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style };
app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], aligns: Vec::new(), ..style };
}
}
}
None if app.quick_tool == crate::QuickTool::AddText => {
crate::content_ui::hint(ui, t);
let mut style = app.text_style.clone();
added_text_format(ui, t, &mut style);
added_text_format(ui, t, &mut style, None);
app.text_style = style;
}
None => {}
}
}
/// Format text for added text: the standard families, size, colour, style and alignment.
fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) {
/// Format text for added text: the standard families, size, colour, style and alignment
/// (of the lines `paragraphs` selects, while the text is being typed).
fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option<crate::format_text_ui::Paragraphs>) {
let shown = style.family.label().to_string();
let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown });
let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }, paragraphs);
if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {
style.family = family;
}
}
impl PdfCraftApp {
/// Format text changed while added text is being typed in view `index`: the box takes the new
/// style at once and stays open. Text already on the page is updated with what has been typed,
/// as one undoable change; new text waits until it is finished. Text typed after this styling
/// starts from it too.
pub(crate) fn restyle_draft(&mut self, index: usize, style: pdfcraft_engine::AddedText) {
let Some(view) = self.views.get_mut(index) else { return };
// Text the standard fonts can't draw stays in the box, unwritten, until it is corrected (#125).
let blocked = view.content.blocked().is_some();
let Some(d) = view.content.draft.as_mut() else { return };
let committed = d.style.clone();
// `text` and `rect` stay what the page has; the rest is the new style.
d.style = pdfcraft_engine::AddedText { text: committed.text.clone(), rect: committed.rect, ..style };
d.focus = true;
self.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], aligns: Vec::new(), ..d.style.clone() };
let (page, item) = (d.page, d.index);
let text = d.text.trim_end().to_string();
let Some(item) = item.filter(|_| !text.trim().is_empty() && !blocked) else { return };
let aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns);
let updated = pdfcraft_engine::AddedText { text, rect: d.rect, aligns, ..d.style.clone() };
if updated == committed {
return;
}
if self.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index: item, content: pdfcraft_engine::AddedContent::Text(updated.clone()) })
&& let Some(d) = self.views.get_mut(index).and_then(|v| v.content.draft.as_mut())
{
// What the page has now. The aligns stay those of the text in the box.
d.style = pdfcraft_engine::AddedText { aligns: std::mem::take(&mut d.style.aligns), ..updated };
// The page has the box's text: its typing history restarts at the next claim, even
// when the restyle left the text as it was, so a Redo can't put undone typing back.
d.typing_from = None;
}
}
/// Apply the open paragraph's formatting as one change; the paragraph stays open.
pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) {
let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
+56 -4
View File
@@ -40,6 +40,44 @@ pub(crate) struct Fonts<'a> {
pub shown: String,
}
/// Text being typed in, for the alignment buttons: with some of it selected, they show and set the
/// alignment of the paragraphs (lines between `\n`s) the selection touches; with none, the box's.
pub(crate) struct Paragraphs<'a> {
pub text: &'a str,
/// A byte range of `text`; empty or `None` is nothing selected.
pub selection: Option<std::ops::Range<usize>>,
/// The box's own alignment is known (added text), so a paragraph set to it keeps no entry of its
/// own. An edited paragraph's alignment is only guessed from where its rows sit: its rows keep
/// every alignment set.
pub tidy: bool,
}
impl Paragraphs<'_> {
/// The paragraphs a selection touches, or `None` with nothing selected.
fn selected(&self) -> Option<std::ops::RangeInclusive<usize>> {
self.selection.clone().filter(|r| !r.is_empty()).map(|r| pdfcraft_engine::paragraph_align::selected(self.text, r))
}
/// The alignment the buttons show: the first selected paragraph's, or the box's.
fn shown(&self, look: &AddedText) -> TextAlign {
self.selected().map_or(look.align, |which| look.paragraph_align(*which.start()))
}
/// `align` picked: for the selected paragraphs, or the whole box (dropping theirs).
fn choose(&self, look: &mut AddedText, align: TextAlign) {
match self.selected() {
Some(which) => {
let count = pdfcraft_engine::paragraph_align::count(self.text);
look.aligns = pdfcraft_engine::paragraph_align::set(&look.aligns, count, which, align, self.tidy.then_some(look.align));
}
None => {
look.align = align;
look.aligns.clear();
}
}
}
}
/// What the panel changed: the style (in place) and a font picked from the list.
#[derive(Default)]
pub(crate) struct Outcome {
@@ -71,7 +109,14 @@ fn number(v: f64) -> String {
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome {
pub(crate) fn panel(
ui: &mut egui::Ui,
t: &Tokens,
look: &mut AddedText,
extras: Option<&mut Extras>,
fonts: Fonts,
paragraphs: Option<Paragraphs>,
) -> Outcome {
let before = (look.clone(), extras.as_deref().copied());
let mut out = Outcome::default();
widgets::section_title(ui, tl!("Format text"));
@@ -140,7 +185,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
ui.painter().vline(sep.center().x, sep.y_range().shrink(3.0), Stroke::new(1.0, t.divider));
planned(ui, "list", tl!("Bulleted list"));
planned(ui, "list-ordered", tl!("Numbered list"));
let current = match look.align {
let shown = paragraphs.as_ref().map_or(look.align, |p| p.shown(look));
let current = match shown {
TextAlign::Left => "align-left",
TextAlign::Center => "align-center",
TextAlign::Right => "align-right",
@@ -156,8 +202,14 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
(TextAlign::Right, "align-right", tl!("Align right")),
(TextAlign::Justify, "align-justify", tl!("Justify")),
] {
if icons::button(ui, icon, 30.0, look.align == a, tip).clicked() {
look.align = a;
if icons::button(ui, icon, 30.0, shown == a, tip).clicked() {
match &paragraphs {
Some(p) => p.choose(look, a),
None => {
look.align = a;
look.aligns.clear();
}
}
ui.close();
}
}
+347 -73
View File
@@ -1,8 +1,9 @@
//! Prepare a form ▸ Calculation order, a page of the left panel, and what it shows on the page.
//!
//! The panel: a card that says whether the order is right and what Fix order does, a card for
//! each loop, and the calculated fields in the order they calculate, each with its formula, the
//! value it shows and what is wrong with its place. A field moves by dragging, with the arrows of
//! each loop, a card for the formulas that read fields the form doesn't have, and the calculated
//! fields in the order they calculate, each with its formula, the value it shows and what is wrong
//! with it. A field moves by dragging, with the arrows of
//! the selected field (Acrobat's Set Field Calculation Order has Up and Down), or with Alt+↑/↓.
//! Every change is one undoable edit.
//!
@@ -17,6 +18,7 @@ use crate::canvas::PageToolContext;
use crate::theme::{self, Tokens};
use crate::{LeftPanel, PdfCraftApp, icons, widgets};
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Vec2, pos2, vec2};
use pdfcraft_cos::ObjRef;
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, format_value};
use pdfcraft_engine::{CalculatedField, CalculationIssue, CalculationOrder, Edit, FormField, FormFieldKind};
@@ -32,13 +34,15 @@ const MAX_LINKS: usize = 20_000;
const MAX_NAMES: usize = 6;
/// Loop cards shown at most.
const MAX_LOOPS: usize = 5;
/// Fields too early that the card names one by one.
const MAX_LATE: usize = 3;
/// Fields a card names one by one: too early, or reading fields the form doesn't have.
const MAX_LISTED: usize = 3;
/// How a field's place in the order, or a link between two fields, stands.
/// How a field's formula and place in the order, or a link between two fields, stand.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Tone {
Ok,
/// Its formula reads a field the form doesn't have.
Missing,
/// It calculates before a field it reads, so it uses that field's old value.
Late,
/// Its formula reads its own result through other formulas.
@@ -51,9 +55,9 @@ enum Tone {
pub struct CalcOrderView {
cache: Option<(u64, Arc<Listed>)>,
/// The field whose row is under the pointer, this frame.
hover_row: Option<String>,
hover_row: Option<Focus>,
/// The field under the pointer on the page, last frame.
hover_page: Option<String>,
hover_page: Option<Focus>,
/// Links on page is off.
hide_links: bool,
/// The selected field the list last scrolled to, so a new selection (on the page too) brings
@@ -85,9 +89,47 @@ struct Row {
struct Link {
from: String,
to: String,
/// The calculated field `to` names, whose formula reads `from`.
reader: ObjRef,
tone: Tone,
}
/// The field in focus: its name, and its object when the focus is one field (its row, or its
/// widget on the page). The Prepare selection has only a name, so it stands for every field of
/// that name: two fields can share a name in a malformed form.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Focus {
name: String,
obj: Option<ObjRef>,
}
impl Focus {
fn named(name: &str) -> Self {
Self { name: name.to_owned(), obj: None }
}
/// A calculated field's row.
fn row(f: &CalculatedField) -> Self {
Self { name: f.name.clone(), obj: Some(f.obj) }
}
/// Whether field `name`, object `obj`, is in focus.
fn is(&self, name: &str, obj: ObjRef) -> bool {
self.name == name && self.obj.is_none_or(|o| o == obj)
}
/// `l` brings a value into the focus: its formula reads `l.from`.
fn reads(&self, l: &Link) -> bool {
self.is(&l.to, l.reader)
}
/// `l` takes the focus's value to a formula. A formula reads by name, so it reads every field
/// of the focus's name alike.
fn read_by(&self, l: &Link) -> bool {
l.from == self.name
}
}
impl CalcOrderView {
fn listed(&mut self, doc: &pdfcraft_engine::Document) -> Arc<Listed> {
let generation = doc.edit_generation();
@@ -109,18 +151,20 @@ impl CalcOrderView {
}
/// The field under the pointer on the page, for the next frame.
pub(crate) fn set_hover_page(&mut self, name: Option<String>) {
self.hover_page = name;
pub(crate) fn set_hover_page(&mut self, field: Option<Focus>) {
self.hover_page = field;
}
/// The field whose links show: under the pointer in the list or on the page, else selected.
fn focus(&self, selected: Option<&str>) -> Option<String> {
self.hover_row.clone().or_else(|| self.hover_page.clone()).or_else(|| selected.map(str::to_owned))
fn focus(&self, selected: Option<&str>) -> Option<Focus> {
self.hover_row.clone().or_else(|| self.hover_page.clone()).or_else(|| selected.map(Focus::named))
}
}
fn tone(issues: &[CalculationIssue]) -> Tone {
if issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { .. })) {
if issues.iter().any(|i| matches!(i, CalculationIssue::MissingField { .. })) {
Tone::Missing
} else if issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { .. })) {
Tone::Loop
} else if issues.iter().any(|i| matches!(i, CalculationIssue::RunsTooEarly { .. })) {
Tone::Late
@@ -134,16 +178,14 @@ fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(200).collect()
}
/// Each calculated field's value, formula and standing, in the order's order.
/// Each calculated field's value, formula and standing, in the order's order. Fields are found by
/// their object, not their name: two calculated fields can share a name in a malformed form.
fn rows(calculated: &[CalculatedField], form: &[FormField]) -> Vec<Row> {
let mut by_name: HashMap<&str, &FormField> = HashMap::with_capacity(form.len());
for f in form {
by_name.entry(f.name.as_str()).or_insert(f);
}
let by_obj: HashMap<_, &FormField> = form.iter().map(|f| (f.obj, f)).collect();
calculated
.iter()
.map(|c| {
let field = by_name.get(c.name.as_str());
let field = by_obj.get(&c.obj);
let value = match field {
Some(f) if f.kind == FormFieldKind::Text => f.value.first().map(|v| format_value(&f.actions.format, v)).unwrap_or_default(),
Some(f) => f.display_value(),
@@ -197,7 +239,7 @@ fn links(calculated: &[CalculatedField], mut names: Vec<&str>) -> Vec<Link> {
} else {
Tone::Ok
};
out.push(Link { from: from.to_owned(), to: c.name.clone(), tone });
out.push(Link { from: from.to_owned(), to: c.name.clone(), reader: c.obj, tone });
}
}
}
@@ -232,7 +274,7 @@ fn loops(calculated: &[CalculatedField]) -> Vec<Vec<&str>> {
}
}
let mut groups: Vec<(usize, Vec<&str>)> = Vec::new();
for (k, f) in calculated.iter().enumerate().filter(|(_, f)| tone(&f.issues) == Tone::Loop) {
for (k, f) in calculated.iter().enumerate().filter(|(_, f)| f.issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { .. }))) {
let r = root(&first, k);
match groups.iter_mut().find(|(g, _)| *g == r) {
Some((_, names)) => names.push(&f.name),
@@ -253,29 +295,52 @@ fn names(list: &[&str]) -> String {
}
/// What the focused field reads and what reads it: "total reads sub and tax · No field reads it".
fn trace(links: &[Link], focus: &str) -> String {
/// If the focus calculates (its fields in the order's `calculated`), the fields it reads that the
/// form doesn't have, and names its script builds as it runs, are said too.
fn trace(links: &[Link], focus: &Focus, calculated: &[CalculatedField]) -> String {
let lang = crate::i18n::current();
let mut reads: Vec<&str> = Vec::new();
let mut read_by: Vec<&str> = Vec::new();
for l in links {
if l.to == focus && !reads.contains(&l.from.as_str()) {
if focus.reads(l) && !reads.contains(&l.from.as_str()) {
reads.push(&l.from);
}
if l.from == focus && !read_by.contains(&l.to.as_str()) {
if focus.read_by(l) && !read_by.contains(&l.to.as_str()) {
read_by.push(&l.to);
}
}
let reads = if reads.is_empty() {
crate::i18n::fmt(tl!("{field} reads no other field"), &[("field", focus)])
} else {
let (mut missing, mut said): (Vec<&str>, HashSet<&str>) = (Vec::new(), HashSet::new());
let mut built = false;
for issue in calculated.iter().filter(|c| focus.is(&c.name, c.obj)).flat_map(|c| &c.issues) {
match issue {
CalculationIssue::MissingField { field } if said.insert(field) => missing.push(field),
CalculationIssue::BuiltNames => built = true,
_ => {}
}
}
reads.extend(&missing);
let focus = focus.name.as_str();
let mut parts = vec![if !reads.is_empty() {
crate::i18n::fmt(tl!("{field} reads {fields}"), &[("field", focus), ("fields", &names(&reads))])
};
let read_by = if read_by.is_empty() {
} else if built {
crate::i18n::fmt(tl!("{field} reads fields named as it runs"), &[("field", focus)])
} else {
crate::i18n::fmt(tl!("{field} reads no other field"), &[("field", focus)])
}];
if !missing.is_empty() {
let template = crate::i18n::trn(lang, missing.len() as u64, "{fields} is missing", "{fields} are missing");
parts.push(crate::i18n::fmt(&template, &[("fields", &names(&missing))]));
}
if built && !reads.is_empty() {
parts.push(tl!("Some names are built as it runs").to_owned());
}
parts.push(if read_by.is_empty() {
tl!("No field reads it").to_owned()
} else {
let template = crate::i18n::trn(crate::i18n::current(), read_by.len() as u64, "{fields} reads it", "{fields} read it");
let template = crate::i18n::trn(lang, read_by.len() as u64, "{fields} reads it", "{fields} read it");
crate::i18n::fmt(&template, &[("fields", &names(&read_by))])
};
format!("{reads} · {read_by}")
});
parts.join(" · ")
}
/// The colours of the cards and the steps in the list, for the theme.
@@ -318,7 +383,7 @@ impl Palette {
match tone {
Tone::Ok => self.step_ok,
Tone::Late => self.step_late,
Tone::Loop => self.step_loop,
Tone::Loop | Tone::Missing => self.step_loop,
}
}
}
@@ -386,6 +451,57 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder,
.collect();
let unlisted: Vec<&str> = order.fields.iter().filter(|f| !f.listed).map(|f| f.name.as_str()).collect();
let loops = loops(&order.fields);
// Each formula that reads fields the form doesn't have, with those fields.
let missing: Vec<(&str, Vec<&str>)> = order
.fields
.iter()
.map(|f| {
let gone = f.issues.iter().filter_map(|i| if let CalculationIssue::MissingField { field } = i { Some(field.as_str()) } else { None });
(f.name.as_str(), gone.collect::<Vec<_>>())
})
.filter(|(_, gone)| !gone.is_empty())
.collect();
if !missing.is_empty() {
let (fill, edge, title, text) = pal.looped;
card(ui, fill, edge, |ui| {
let heading = crate::i18n::trn(
lang,
missing.len() as u64,
"{n} formula reads a field the form doesn't have",
"{n} formulas read fields the form doesn't have",
);
card_title(ui, "triangle-alert", &heading, title);
for (field, gone) in missing.iter().take(MAX_LISTED) {
let template = crate::i18n::trn(
lang,
gone.len() as u64,
"{field} reads {fields}, which the form doesn't have.",
"{field} reads {fields}, which the form doesn't have.",
);
card_text(ui, &crate::i18n::fmt(&template, &[("field", field), ("fields", &names(gone))]), text);
}
if missing.len() > MAX_LISTED {
let more = (missing.len() - MAX_LISTED) as u64;
card_text(
ui,
&crate::i18n::trn(lang, more, "{n} more formula reads a missing field.", "{n} more formulas read missing fields."),
text,
);
}
if editable
&& let Some((first, _)) = missing.first()
&& text_button(ui, &crate::i18n::fmt(tl!("Edit {field}'s formula"), &[("field", first)]), t)
{
act = Some(Act::EditFormula((*first).to_owned()));
}
// The all-clear, which carries Undo after a reorder, stays hidden while a formula is
// broken, so Undo is offered here.
if undo && text_button(ui, tl!("Undo"), t) {
act = Some(Act::Undo);
}
});
ui.add_space(6.0);
}
if order.fixable() {
let (fill, edge, title, text) = pal.warn;
card(ui, fill, edge, |ui| {
@@ -395,7 +511,7 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder,
crate::i18n::trn(lang, late.len() as u64, "{n} field shows an old value", "{n} fields show old values")
};
card_title(ui, "triangle-alert", &heading, title);
for (field, later) in late.iter().take(MAX_LATE) {
for (field, later) in late.iter().take(MAX_LISTED) {
let template = crate::i18n::trn(
lang,
later.len() as u64,
@@ -404,8 +520,8 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder,
);
card_text(ui, &crate::i18n::fmt(&template, &[("field", field), ("fields", &names(later))]), text);
}
if late.len() > MAX_LATE {
let more = (late.len() - MAX_LATE) as u64;
if late.len() > MAX_LISTED {
let more = (late.len() - MAX_LISTED) as u64;
card_text(ui, &crate::i18n::trn(lang, more, "{n} more field calculates too early.", "{n} more fields calculate too early."), text);
}
if !unlisted.is_empty() {
@@ -435,7 +551,7 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder,
ui.add(egui::Label::new(egui::RichText::new(tl!("Moves only what must move")).font(theme::regular(11.5)).color(t.text_muted)).wrap());
});
});
} else {
} else if missing.is_empty() {
let (fill, edge, colour) = pal.good;
card(ui, fill, edge, |ui| {
let said = if loops.is_empty() {
@@ -467,6 +583,19 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder,
}
});
}
let built: Vec<&str> = order.fields.iter().filter(|f| f.issues.contains(&CalculationIssue::BuiltNames)).map(|f| f.name.as_str()).collect();
if !built.is_empty() {
ui.add_space(6.0);
card(ui, t.card, t.border, |ui| {
let template = crate::i18n::trn(
lang,
built.len() as u64,
"{fields} builds some field names as it runs, so those reads aren't checked.",
"{fields} build some field names as they run, so those reads aren't checked.",
);
card_text(ui, &crate::i18n::fmt(&template, &[("fields", &names(&built))]), t.text_muted);
});
}
act
}
@@ -475,6 +604,9 @@ fn explained(issues: &[CalculationIssue]) -> String {
issues
.iter()
.map(|i| match i {
CalculationIssue::MissingField { field } => {
crate::i18n::fmt(tl!("Reads {name}, which the form doesn't have, so it gets no value there."), &[("name", field)])
}
CalculationIssue::RunsTooEarly { field } => {
crate::i18n::fmt(tl!("Reads {name}, which calculates later, so it shows an old value."), &[("name", field)])
}
@@ -485,6 +617,9 @@ fn explained(issues: &[CalculationIssue]) -> String {
CalculationIssue::NotInOrder => {
tl!("Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.").to_owned()
}
CalculationIssue::BuiltNames => {
tl!("Its script also reads fields whose names it builds as it runs, so those reads can't be checked.").to_owned()
}
})
.collect::<Vec<_>>()
.join("\n")
@@ -571,14 +706,14 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
let pointed = ui.rect_contains_pointer(rect);
if pointed {
hover_row = Some(f.name.clone());
hover_row = Some(Focus::row(f));
}
let visible = ui.is_rect_visible(rect);
// The arrows' column, kept for every row so nothing moves when they show.
let arrows_x = rect.right() - 26.0;
if visible {
let press = theme::Press::track(ui, &resp);
if !is_selected && focus_page.as_deref() == Some(f.name.as_str()) {
if !is_selected && focus_page.as_ref().is_some_and(|p| p.is(&f.name, f.obj)) {
ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover);
}
press.wash(ui, rect, 8, is_selected);
@@ -592,9 +727,11 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
let text_x = body.left() + 38.0;
let value_end = arrows_x - 4.0;
let caption = match row.tone {
Tone::Missing => Some((tl!("Missing field"), pal.step_loop.1)),
Tone::Loop => Some((tl!("Loop"), pal.step_loop.1)),
Tone::Late => Some((tl!("Too early"), pal.step_late.1)),
Tone::Ok if !f.listed => Some((tl!("Not in order"), pal.step_late.1)),
Tone::Ok if f.issues.contains(&CalculationIssue::BuiltNames) => Some((tl!("Unchecked reads"), t.text_muted)),
Tone::Ok => None,
};
let value_font = theme::medium(13.0);
@@ -608,7 +745,10 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
ui.painter().text(pos2(value_end, bottom), Align2::RIGHT_CENTER, c, font, colour);
w
});
let room = (value_end - text_x - value_w.max(caption_w) - 10.0).max(0.0);
// The name shares the top line with the value, the formula the bottom one with the
// caption.
let room = (value_end - text_x - value_w - 10.0).max(0.0);
let formula_room = (value_end - text_x - caption_w - 10.0).max(0.0);
let name_font = theme::medium(13.0);
let tag = row.script.then(|| (tl!("Script"), widgets::text_width(ui, tl!("Script"), &theme::semibold(9.5)) + 8.0));
let name_room = (room - tag.map_or(0.0, |(_, w)| w + 6.0)).max(0.0);
@@ -619,7 +759,7 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
ui.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
ui.painter().text(chip.center(), Align2::CENTER_CENTER, label, theme::semibold(9.5), t.text_muted);
}
widgets::paint_left(ui, pos2(text_x, bottom), &row.formula, egui::FontId::monospace(11.0), t.text_muted, room);
widgets::paint_left(ui, pos2(text_x, bottom), &row.formula, egui::FontId::monospace(11.0), t.text_muted, formula_room);
}
// The selected field's arrows, or the one under the pointer.
if editable && (is_selected || pointed) {
@@ -720,6 +860,16 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
}
/// The focus's links, and whether a field is at either end of one or is the focus itself: the
/// fields it reads go by name, as a read name stands for every field of that name, and the
/// formulas that read it by object.
fn focus_links<'a>(links: &'a [Link], focus: &'a Focus) -> (Vec<&'a Link>, impl Fn(&str, ObjRef) -> bool + 'a) {
let shown: Vec<&Link> = links.iter().filter(|l| focus.reads(l) || focus.read_by(l)).collect();
let names: HashSet<&str> = shown.iter().filter(|l| focus.reads(l)).map(|l| l.from.as_str()).collect();
let readers: HashSet<ObjRef> = shown.iter().filter(|l| focus.read_by(l)).map(|l| l.reader).collect();
(shown, move |name: &str, obj: ObjRef| focus.is(name, obj) || names.contains(name) || readers.contains(&obj))
}
/// The calculation order on page `page`: the links of the field in focus, with the fields it has
/// nothing to do with faded, and the steps of the focus and its linked fields; with nothing in
/// focus, the steps of the fields whose place is wrong. A form with many calculations stays
@@ -730,57 +880,52 @@ pub(crate) fn paint_page(
view: &CalcOrderView,
generation: u64,
selected: Option<&str>,
) -> Option<String> {
) -> Option<Focus> {
let PageToolContext { ui, xf, page, info, ptr, .. } = cx;
let painter = ui.painter();
let pointer = ptr.pos;
let listed = view.current(generation)?;
// Each field's first widget on this page.
let mut on_page: Vec<(&str, Rect)> = Vec::new();
let mut on_page: Vec<(&str, ObjRef, Rect)> = Vec::new();
for f in form {
if let Some(w) = f.widgets.iter().find(|w| w.page == Some(page) && !w.hidden) {
let r = w.rect;
on_page.push((&f.name, xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32])));
on_page.push((&f.name, f.obj, xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32])));
}
}
if on_page.is_empty() {
return None;
}
let hovered = pointer.and_then(|p| on_page.iter().rev().find(|(_, r)| r.contains(p))).map(|(n, _)| (*n).to_owned());
let at: HashMap<&str, Rect> = on_page.iter().copied().collect();
let hovered = pointer
.and_then(|p| on_page.iter().rev().find(|(_, _, r)| r.contains(p)))
.map(|(name, obj, _)| Focus { name: (*name).to_owned(), obj: Some(*obj) });
// Arrows start at a read name's field (one of them, if fields share it) and end at the formula's.
let by_name: HashMap<&str, Rect> = on_page.iter().map(|(name, _, r)| (*name, *r)).collect();
let by_obj: HashMap<ObjRef, Rect> = on_page.iter().map(|(_, obj, r)| (*obj, *r)).collect();
let zoom = xf.zoom().clamp(0.5, 3.0);
let focus = if view.hide_links { None } else { view.focus(selected) };
// The focus's links, and the fields at either end of them (with the focus itself).
let shown: Vec<&Link> = focus.as_deref().map(|f| listed.links.iter().filter(|l| l.from == f || l.to == f).collect()).unwrap_or_default();
let related: Option<HashSet<&str>> = focus.as_deref().map(|f| shown.iter().flat_map(|l| [l.from.as_str(), l.to.as_str()]).chain([f]).collect());
if let (Some(focus), Some(related)) = (focus.as_deref(), &related) {
for (name, r) in &on_page {
if !related.contains(name) {
let focused = focus.as_ref().map(|f| focus_links(&listed.links, f));
if let (Some(focus), Some((shown, related))) = (&focus, &focused) {
for (name, obj, r) in &on_page {
if !related(name, *obj) {
painter.rect_filled(r.expand(1.0), CornerRadius::ZERO, Color32::from_white_alpha(150));
} else {
let width = if *name == focus { 2.0 } else { 1.0 };
let width = if focus.is(name, *obj) { 2.0 } else { 1.0 };
painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(width, LINK_OK), egui::StrokeKind::Outside);
}
}
for l in &shown {
if let (Some(a), Some(b)) = (at.get(l.from.as_str()), at.get(l.to.as_str())) {
for l in shown {
if let (Some(a), Some(b)) = (by_name.get(l.from.as_str()), by_obj.get(&l.reader)) {
arrow(painter, *a, *b, l.tone, zoom);
}
}
}
for (k, (f, row)) in listed.order.fields.iter().zip(&listed.rows).enumerate() {
let Some(r) = at.get(f.name.as_str()) else { continue };
let shown = match &related {
Some(related) => related.contains(f.name.as_str()),
None => row.tone != Tone::Ok || !f.listed,
};
if !shown {
continue;
}
let fill = match row.tone {
let related = focused.as_ref().map(|(_, related)| related as Related<'_>);
for (k, r, tone) in steps(&listed, &by_obj, related) {
let fill = match tone {
Tone::Ok => STEP,
Tone::Late => LINK_LATE,
Tone::Loop => LINK_LOOP,
Tone::Loop | Tone::Missing => LINK_LOOP,
};
// No bigger than the field, so a small one keeps its contents in view.
let radius = (8.0 * zoom).clamp(6.5, 10.0).min((r.height() * 0.6).max(5.0));
@@ -791,6 +936,27 @@ pub(crate) fn paint_page(
hovered
}
/// Whether a field, by name and object, is at an end of the focus's links or is the focus.
type Related<'a> = &'a dyn Fn(&str, ObjRef) -> bool;
/// The steps the page shows: each one's place in the order (from 0), the widget it goes on (its
/// own field's, so fields that share a name each get theirs) and its tone. With a focus, the
/// steps of the fields `related` to it; without one, those whose place is wrong.
fn steps(listed: &Listed, by_obj: &HashMap<ObjRef, Rect>, related: Option<Related<'_>>) -> Vec<(usize, Rect, Tone)> {
listed
.order
.fields
.iter()
.zip(&listed.rows)
.enumerate()
.filter(|(_, (f, row))| match related {
Some(related) => related(&f.name, f.obj),
None => row.tone != Tone::Ok || !f.listed,
})
.filter_map(|(k, (f, row))| Some((k, *by_obj.get(&f.obj)?, row.tone)))
.collect()
}
/// Where a ray from `r`'s centre in direction `dir` (a unit vector) leaves `r`.
fn edge(r: Rect, dir: Vec2) -> Pos2 {
let half = r.size() / 2.0;
@@ -807,7 +973,7 @@ fn arrow(painter: &egui::Painter, a: Rect, b: Rect, tone: Tone, zoom: f32) {
let colour = match tone {
Tone::Ok => LINK_OK,
Tone::Late => LINK_LATE,
Tone::Loop => LINK_LOOP,
Tone::Loop | Tone::Missing => LINK_LOOP,
};
let stroke = Stroke::new(1.75, colour);
let head = 9.0;
@@ -853,7 +1019,7 @@ pub(crate) fn paint_trace(ui: &egui::Ui, painter: &egui::Painter, viewport: Rect
}
let (Some(listed), Some(focus)) = (view.current(generation), view.focus(selected)) else { return };
let t = Tokens::get(ui.ctx());
let text = trace(&listed.links, &focus);
let text = trace(&listed.links, &focus, &listed.order.fields);
let font = theme::regular(12.0);
// Clear of the floating tool strip at the view's left.
let room = (viewport.width() - 220.0).max(0.0);
@@ -873,7 +1039,8 @@ mod tests {
use super::*;
fn field(name: &str, reads: &[&str], issues: Vec<CalculationIssue>) -> CalculatedField {
CalculatedField { name: name.into(), reads: reads.iter().map(|r| (*r).to_owned()).collect(), listed: true, issues }
let obj = ObjRef { num: 0, generation: 0 };
CalculatedField { name: name.into(), obj, reads: reads.iter().map(|r| (*r).to_owned()).collect(), listed: true, issues }
}
#[test]
@@ -887,7 +1054,7 @@ mod tests {
}
fn link(from: &str, to: &str, tone: Tone) -> Link {
Link { from: from.into(), to: to.into(), tone }
Link { from: from.into(), to: to.into(), reader: ObjRef { num: 0, generation: 0 }, tone }
}
#[test]
@@ -932,10 +1099,117 @@ mod tests {
fn the_summary_says_what_a_field_reads_and_what_reads_it() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let links = [link("sub", "total", Tone::Late), link("tax", "total", Tone::Ok), link("qty", "sub", Tone::Ok), link("sub", "tax", Tone::Ok)];
assert_eq!(trace(&links, "total"), "total reads sub and tax · No field reads it");
assert_eq!(trace(&links, "sub"), "sub reads qty · total and tax read it");
assert_eq!(trace(&links, "tax"), "tax reads sub · total reads it");
assert_eq!(trace(&links, "qty"), "qty reads no other field · sub reads it");
assert_eq!(trace(&links, &Focus::named("total"), &[]), "total reads sub and tax · No field reads it");
assert_eq!(trace(&links, &Focus::named("sub"), &[]), "sub reads qty · total and tax read it");
assert_eq!(trace(&links, &Focus::named("tax"), &[]), "tax reads sub · total reads it");
assert_eq!(trace(&links, &Focus::named("qty"), &[]), "qty reads no other field · sub reads it");
}
/// A field the form doesn't have is read too, and said to be missing; a script's names built
/// as it runs are said instead of "no other field".
#[test]
fn the_summary_says_what_is_missing_and_what_is_built() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let missing = |names: &[&str]| names.iter().map(|n| CalculationIssue::MissingField { field: (*n).into() }).collect::<Vec<_>>();
let links = [link("qty", "sub", Tone::Ok), link("sub", "total", Tone::Ok), link("tax", "total", Tone::Ok)];
let sub = field("sub", &["qty", "discount"], missing(&["discount"]));
assert_eq!(trace(&links, &Focus::named("sub"), &[sub]), "sub reads qty and discount · discount is missing · total reads it");
let tax = field("tax", &["subtotl", "rate"], missing(&["subtotl", "rate"]));
assert_eq!(trace(&links, &Focus::named("tax"), &[tax]), "tax reads subtotl and rate · subtotl and rate are missing · total reads it");
let rows = field("rows", &[], vec![CalculationIssue::BuiltNames]);
assert_eq!(trace(&links, &Focus::named("rows"), &[rows]), "rows reads fields named as it runs · No field reads it");
let both = field("total", &["sub", "tax"], vec![CalculationIssue::BuiltNames]);
assert_eq!(trace(&links, &Focus::named("total"), &[both]), "total reads sub and tax · Some names are built as it runs · No field reads it");
}
/// Two calculated fields named `total`, as a malformed form can have, each reading a field the
/// form doesn't have, and `grand` reading `total`.
fn two_totals() -> ([CalculatedField; 3], Vec<Link>) {
let total = |num, reads: &[&str], gone: &str| CalculatedField {
obj: ObjRef { num, generation: 0 },
..field("total", reads, vec![CalculationIssue::MissingField { field: gone.into() }])
};
let grand = CalculatedField { obj: ObjRef { num: 3, generation: 0 }, ..field("grand", &["total"], vec![]) };
let calculated = [total(1, &["qty", "lost"], "lost"), total(2, &["tax", "gone"], "gone"), grand];
let links = links(&calculated, vec!["qty", "tax", "total", "total", "grand"]);
(calculated, links)
}
/// Hovering a row of a field that shares its name says that field's reads and missing fields,
/// not the first one's of that name. The selection is only a name, so it says both.
#[test]
fn a_row_sharing_its_name_is_traced_by_its_field() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let (calculated, links) = two_totals();
let mut view = CalcOrderView::default();
let mut said = Vec::new();
for f in &calculated {
view.hover_row = Some(Focus::row(f));
let focus = view.focus(Some("total")).expect("a hovered row is the focus");
said.push(trace(&links, &focus, &calculated));
}
assert_eq!(
said,
[
"total reads qty and lost · lost is missing · grand reads it",
"total reads tax and gone · gone is missing · grand reads it",
"grand reads total · No field reads it",
]
);
view.hover_row = None;
let selected = view.focus(Some("total")).expect("the selection is the focus");
assert_eq!(selected, Focus::named("total"));
assert_eq!(trace(&links, &selected, &calculated), "total reads qty, tax, lost and gone · lost and gone are missing · grand reads it");
}
/// On the page too, a field that shares its name is outlined, and its arrows drawn, as one
/// field: the fields it reads by name, the formulas that read it by object.
#[test]
fn a_field_sharing_its_name_is_related_on_the_page_by_its_object() {
let (calculated, links) = two_totals();
let [first, second, grand] = calculated.each_ref().map(|f| f.obj);
let elsewhere = ObjRef { num: 9, generation: 0 };
let focus = Focus::row(&calculated[0]);
let (shown, related) = focus_links(&links, &focus);
assert!(shown.iter().all(|l| l.reader == first || l.to == "grand"), "{shown:?}");
assert!(related("total", first) && !related("total", second), "only the field in focus");
assert!(related("qty", elsewhere) && !related("tax", elsewhere), "only the fields it reads");
assert!(related("grand", grand), "the formula that reads it");
let selected = Focus::named("total");
let (_, related) = focus_links(&links, &selected);
assert!(related("total", first) && related("total", second) && related("tax", elsewhere), "a selected name is every field of it");
}
/// Each step goes on its own field's widget: two fields named `total` get one each, and a
/// focus on one shows its step and its reader's, not the other's.
#[test]
fn a_step_goes_on_its_own_field_when_names_are_shared() {
let (calculated, links) = two_totals();
let [first, second, grand] = calculated.each_ref().map(|f| f.obj);
let at = |x: f32| Rect::from_min_size(pos2(x, 0.0), vec2(10.0, 10.0));
let by_obj = HashMap::from([(first, at(0.0)), (second, at(100.0)), (grand, at(200.0))]);
let rows = rows(&calculated, &[]);
let mut order = CalculationOrder::default();
order.fields = calculated.to_vec();
let listed = Listed { order, rows, links };
// Unfocused, the two broken formulas show their steps.
assert_eq!(steps(&listed, &by_obj, None), [(0, at(0.0), Tone::Missing), (1, at(100.0), Tone::Missing)]);
let focus = Focus::row(&calculated[0]);
let (_, related) = focus_links(&listed.links, &focus);
assert_eq!(steps(&listed, &by_obj, Some(&related)), [(0, at(0.0), Tone::Missing), (2, at(200.0), Tone::Ok)]);
}
/// A missing field outranks a loop on the row, but the field is still in its loop.
#[test]
fn a_missing_field_marks_the_row_and_keeps_its_loop() {
let gone = CalculationIssue::MissingField { field: "gone".into() };
assert_eq!(tone(&[gone.clone(), CalculationIssue::Cycle { fields: vec!["b".into()] }]), Tone::Missing);
assert_eq!(tone(&[CalculationIssue::BuiltNames]), Tone::Ok, "built names are a note, not a fault");
let calculated = [
field("a", &["b", "gone"], vec![gone, CalculationIssue::Cycle { fields: vec!["b".into()] }]),
field("b", &["a"], vec![CalculationIssue::Cycle { fields: vec!["a".into()] }]),
];
assert_eq!(loops(&calculated), vec![vec!["a", "b"]]);
}
#[test]
+125 -28
View File
@@ -68,9 +68,12 @@ pub struct TextDraft {
/// Display space.
pub rect: [f64; 4],
pub text: String,
/// The box's formatting; its `text` is what the page has (empty for new text).
/// The box's formatting; its `text` and `rect` are what the page has (empty for new text), and
/// its `aligns` follow `text` as it is typed.
pub style: AddedText,
pub focus: bool,
/// The selected bytes of `text` (empty: the caret), for aligning the lines it touches.
pub selection: Option<std::ops::Range<usize>>,
/// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]).
pub typing_from: Option<String>,
}
@@ -433,7 +436,7 @@ pub(crate) fn page_input(
let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64);
let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]];
style.rect = rect;
cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None });
cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, selection: None, typing_from: None });
consumed = true;
if typed.is_some() {
view.pending_edit = typed;
@@ -472,7 +475,16 @@ pub(crate) fn page_input(
if let Some(edit) = cv.finish_draft(added) {
view.pending_edit = Some(edit);
}
cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None });
cv.draft = Some(TextDraft {
page,
index: Some(i),
rect: t.rect,
text: t.text.clone(),
style: t.clone(),
focus: true,
selection: None,
typing_from: None,
});
cv.selected = Some((page, i));
consumed = true;
return consumed;
@@ -589,11 +601,16 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// At least 60 points wide, so a short box can still be clicked into. Where that is wider than
// the box, the editor reaches past it away from the edge its text keeps (the right edge for
// right-aligned text, both halves for centred), so the text stays where the page draws it.
// shortcut: lines aligned differently from each other in a box under 60 points keep the
// editor's left edge; anchor each line on its own if such boxes turn out to matter.
let width = r.width().max(60.0);
let anchor = match draft.style.align {
pdfcraft_engine::TextAlign::Center => 0.5,
pdfcraft_engine::TextAlign::Right => 1.0,
pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => 0.0,
// The alignment every line shares, its own or the box's.
let first = draft.style.paragraph_align(0);
let shared = (1..pdfcraft_engine::paragraph_align::count(&draft.text)).all(|i| draft.style.paragraph_align(i) == first).then_some(first);
let anchor = match shared {
Some(pdfcraft_engine::TextAlign::Center) => 0.5,
Some(pdfcraft_engine::TextAlign::Right) => 1.0,
_ => 0.0,
};
let at = r.min - egui::vec2((width - r.width()) * anchor, 0.0);
let background_ready = draft.index.is_some_and(|index| view.text_background.ready_added(page, index));
@@ -601,6 +618,12 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
let (mut commit, mut cancel, mut done) = (false, false, false);
let mut undrawable = None;
let memo = &view.content.undrawable;
// Clicks outside the document (the Format text panel) keep the text open, as they keep a
// paragraph being edited open.
// Where the click that took the focus was: the pointer itself may be gone by then (a touch, a
// pen lifted away). The keyboard moving there (Tab, a screen reader) keeps it open too.
let away = |p: egui::Pos2| !view.viewport_rect().contains(p);
let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(away) || crate::edit_text_ui::focus_pos(ctx).is_some_and(away);
// Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over
// the page, so only where the document shows: text scrolled under the mode bar or a side
// panel must leave them their pointer (#2755).
@@ -619,41 +642,51 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
// Laid out as the page draws it, so opening the box doesn't show it realigned.
// shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter.
let halign = match t.style.align {
pdfcraft_engine::TextAlign::Center => egui::Align::Center,
pdfcraft_engine::TextAlign::Right => egui::Align::Max,
pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min,
};
let color = Color32::from_rgb(cr, cg, cb);
// Character spacing shows as the page will draw it, scaled like the font.
let letter_spacing = (t.style.char_spacing * f64::from(zoom)) as f32;
let mut spaced = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| {
let job = spaced_job(text.as_str(), &font, Color32::from_rgb(cr, cg, cb), letter_spacing, wrap_width, halign);
ui.fonts_mut(|f| f.layout_job(job))
// Laid out as the page draws it, each paragraph with its own alignment, so opening the
// box doesn't show it realigned.
let style = t.style.clone();
let line_font = font.clone();
let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| {
aligned_galley(ui, text.as_str(), &style, &line_font, color, letter_spacing, wrap)
};
let before = t.text.clone();
let input = input_id();
t.claim_typing(ui.ctx(), doc_id);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let mut edit = egui::TextEdit::multiline(&mut t.text)
.font(font.clone())
let output = egui::TextEdit::multiline(&mut t.text)
.font(font)
.desired_width(width)
.desired_rows(1)
.align(egui::Align2::new(halign, egui::Align::TOP))
.layouter(&mut layouter)
// Keep the page editor's original inset; the theme's larger button padding
// otherwise shifts narrow right-aligned boxes away from their text edge.
.margin(egui::Margin::symmetric(4, 2))
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.text_color(color)
.hint_text(tl!("Type text"))
.id(input);
if letter_spacing != 0.0 {
edit = edit.layouter(&mut spaced);
.id(input)
.show(ui);
let resp = output.response;
if let Some(cursor) = output.cursor_range {
let chars = cursor.as_sorted_char_range();
let byte = |index: usize| t.text.char_indices().nth(index).map_or(t.text.len(), |(at, _)| at);
t.selection = Some(byte(chars.start.into())..byte(chars.end.into()));
}
let resp = ui.add(edit).accessible_name(tl!("Added text"));
// Each paragraph keeps its own alignment through the typing.
if t.text != before {
crate::edit_text_ui::follow_typing(ui.ctx(), input, &before, &t.text, &mut [&mut t.style.aligns]);
}
// The editor has no visible label; name it for assistive tech.
resp.response.clone().accessible_name(tl!("Added text"));
if t.focus {
resp.request_focus();
if let Some(range) = &t.selection {
crate::edit_text_ui::reselect(ui.ctx(), input, &t.text, range);
}
t.focus = false;
}
let buttons = egui::Frame::NONE
@@ -670,10 +703,12 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
})
.response
.rect;
// Pressing a button takes the focus from the text: that is not a click away.
let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p));
// Pressing a button, or moving the keyboard to one, takes the focus from the text:
// that is not a click away.
let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p))
|| crate::edit_text_ui::focus_pos(ui.ctx()).is_some_and(|p| buttons.contains(p));
cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor;
commit = done || resp.lost_focus() && !on_buttons && !outside && !view.objects.hold_editor;
});
// Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
undrawable = self::undrawable(t, memo);
@@ -696,7 +731,17 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
/// A finished draft as an edit: new text is added (and selected once it exists), retyped text
/// updated, and text emptied by retyping removed. Empty new text adds nothing.
fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option<Edit> {
// Unchanged is no edit, even for text saved with trailing spaces the trim would drop.
if let Some(i) = d.index
&& let Some((_, a)) = on_page(added, d.page).into_iter().find(|(k, _)| *k == i)
&& let AddedContent::Text(old) = &a.content
&& *old == (AddedText { text: d.text.clone(), rect: d.rect, ..d.style.clone() })
{
return None;
}
let text = d.text.trim_end().to_string();
let aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns);
let d = TextDraft { style: AddedText { aligns, ..d.style }, ..d };
match d.index {
None if text.trim().is_empty() => None,
None => {
@@ -722,6 +767,58 @@ pub(crate) fn input_id() -> egui::Id {
egui::Id::unique("added-text-input")
}
/// `text` laid out as added text with `style` is drawn: each paragraph (line typed with Enter) wrapped
/// to `wrap` and placed by its own alignment, a justified one with every row but its last
/// stretched.
fn aligned_galley(
ui: &egui::Ui,
text: &str,
style: &AddedText,
font: &egui::FontId,
color: Color32,
letter_spacing: f32,
wrap: f32,
) -> std::sync::Arc<egui::Galley> {
use pdfcraft_engine::TextAlign;
let job = |text: &str, align: TextAlign| {
let mut job = spaced_job(text, font, color, letter_spacing, wrap, egui::Align::Min);
job.justify = align == TextAlign::Justify;
job
};
let uniform = text.split('\n').enumerate().all(|(i, _)| style.paragraph_align(i) == style.align);
if uniform && style.align != TextAlign::Center && style.align != TextAlign::Right {
return ui.fonts_mut(|f| f.layout_job(job(text, style.align)));
}
// Each paragraph on its own (egui aligns a whole job one way), its rows moved within the
// box's width, then joined in order: the caret and selection see one text.
let mut galleys = Vec::new();
let mut start = 0;
for (i, paragraph) in text.split('\n').enumerate() {
let end = start + paragraph.len();
// The last paragraph keeps a trailing break, as egui's own split does.
let last = end + 1 == text.len();
let piece = if last { text.get(start..).unwrap_or_default() } else { paragraph };
let mut galley = ui.fonts_mut(|f| f.layout_job(job(piece, style.paragraph_align(i))));
let g = std::sync::Arc::make_mut(&mut galley);
for placed in &mut g.rows {
let free = (wrap - placed.row.size.x).max(0.0);
placed.pos.x = match style.paragraph_align(i) {
TextAlign::Center => free / 2.0,
TextAlign::Right => free,
TextAlign::Left | TextAlign::Justify => 0.0,
};
}
g.rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(g.rect.width().max(wrap), g.rect.height()));
galleys.push(galley);
if last {
break;
}
start = end + 1;
}
let whole = std::sync::Arc::new(spaced_job(text, font, color, letter_spacing, wrap, egui::Align::Min));
std::sync::Arc::new(egui::Galley::concat(whole, &galleys, ui.ctx().pixels_per_point()))
}
/// How to use the tools, under the Add content list.
pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) {
ui.label(
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more