mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Fix main's build and tests after the latest merges (#6706)
This commit is contained in:
1 parent
dafb3be3de
commit
9ee213eff7
24 files changed
+41
-959
No files matched your search
@@ -38,14 +38,8 @@ fn bad(m: impl Into<String>) -> ToolError {
|
||||
}
|
||||
|
||||
/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS`
|
||||
/// index and size (or a size such as "8.5x14in", whose index is past the list); US Letter when
|
||||
/// none is given.
|
||||
/// index and size; US Letter when none is given.
|
||||
fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
|
||||
// A size of its own: "8.5x14in", "210x297mm", "10x15cm", "612x792pt". It has no place in
|
||||
// `PAPERS` (index `PAPERS.len()`), so no `media` option is sent for it.
|
||||
if let Some(size) = name.and_then(paper_size) {
|
||||
return Ok((PAPERS.len(), size));
|
||||
}
|
||||
let mut papers = PAPERS.iter().enumerate();
|
||||
let found = match name {
|
||||
None => papers.next(),
|
||||
@@ -57,7 +51,7 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
|
||||
};
|
||||
found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)",
|
||||
"unknown paper {:?}; choose one of {}",
|
||||
name.unwrap_or_default(),
|
||||
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
@@ -243,19 +237,6 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200
|
||||
/// inches (PDF's own page limit).
|
||||
fn paper_size(spec: &str) -> Option<(f64, f64)> {
|
||||
let s = spec.trim().to_ascii_lowercase();
|
||||
let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)]
|
||||
.into_iter()
|
||||
.find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?;
|
||||
let (w, h) = body.split_once('x')?;
|
||||
let side = |t: &str| t.trim().parse::<f64>().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v));
|
||||
let (w, h) = (side(w)?, side(h)?);
|
||||
Some((w.min(h), w.max(h)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -308,19 +289,7 @@ mod tests {
|
||||
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
|
||||
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
|
||||
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
|
||||
// A size of its own is laid out as asked and names no media.
|
||||
assert_eq!(paper_named(Some("8.5x14in")).ok(), Some((PAPERS.len(), (612.0, 1008.0))));
|
||||
assert!(job(json!({}), Some("8.5x14in")).options.is_empty());
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paper_size_reads_its_numbers_and_needs_a_unit() {
|
||||
assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0)));
|
||||
assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0)));
|
||||
for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] {
|
||||
assert_eq!(paper_size(spec), None, "{spec}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -741,7 +741,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"cut_marks": { "type": "boolean" },
|
||||
"orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] },
|
||||
"comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] },
|
||||
"paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." },
|
||||
"paper": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"printer": { "type": "string" },
|
||||
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
|
||||
|
||||
@@ -11161,32 +11161,6 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres,
|
||||
/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn doc_print_takes_a_paper_size_of_its_own() {
|
||||
let dir = workdir("print-paper-size");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (paper, want) in
|
||||
[("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))]
|
||||
{
|
||||
let file = format!("{}.pdf", want.0);
|
||||
ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file }));
|
||||
let bytes = std::fs::read(dir.join(&file)).unwrap();
|
||||
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let page = pdfcraft_model::pages(&out)[0].display_size(&out);
|
||||
assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}");
|
||||
}
|
||||
for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] {
|
||||
match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) {
|
||||
Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"),
|
||||
other => panic!("{paper}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside
|
||||
/// 72–600 is refused.
|
||||
#[test]
|
||||
|
||||
@@ -3240,175 +3240,6 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() {
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" "), text);
|
||||
}
|
||||
|
||||
/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words
|
||||
/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph),
|
||||
/// one font per word, and the line reads back whole and in place: one line, in its own font, so
|
||||
/// editing it again replaces all of it (review on #2955), also when it starts with a substitute
|
||||
/// word.
|
||||
#[test]
|
||||
fn only_the_words_a_subset_cannot_show_are_substituted() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap();
|
||||
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
|
||||
let mut doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
|
||||
assert_eq!(read, [("ab ba cab ba", "F2")]);
|
||||
// Each word where its font puts it, the substituted `cab` included: no gap or overlap opens
|
||||
// where the font changes (review on #2955).
|
||||
let starts: Vec<f64> = lines[0].ink.iter().map(|b| b[0]).collect();
|
||||
assert_eq!(starts.len(), 4, "{:?}", lines[0].ink);
|
||||
for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) {
|
||||
assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them");
|
||||
}
|
||||
assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink);
|
||||
for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] {
|
||||
text::replace_line(&mut doc, 0, 0, text).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect();
|
||||
assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine`
|
||||
/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader
|
||||
/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content
|
||||
/// doesn't grow from one edit to the next.
|
||||
#[test]
|
||||
fn replacing_a_mixed_font_line_again_replaces_its_sequence() {
|
||||
let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET");
|
||||
let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count();
|
||||
let mut sizes = Vec::new();
|
||||
for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] {
|
||||
text::replace_line(&mut doc, 0, 0, text).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let mixed = text.contains('c');
|
||||
assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}");
|
||||
assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}");
|
||||
assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect();
|
||||
assert_eq!(read, [text, "ba"], "after {text:?}");
|
||||
assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place");
|
||||
sizes.push(content.split_whitespace().count());
|
||||
}
|
||||
assert_eq!(sizes[2], sizes[3], "the same text again, the same content");
|
||||
// A sequence holding marked content of its own goes whole, and what follows it keeps its
|
||||
// font and size unless the replacement sets the same again.
|
||||
for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] {
|
||||
let mut doc = text_page(&format!(
|
||||
"BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET"
|
||||
));
|
||||
text::replace_line(&mut doc, 0, 0, "ab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect();
|
||||
assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the
|
||||
/// subset can show) was split into an empty source-font run and a substitute run, so it was
|
||||
/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line
|
||||
/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is
|
||||
/// substituted as a whole instead, as a line with no word in the source font always was.
|
||||
#[test]
|
||||
fn a_leading_space_does_not_make_a_source_font_run() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
|
||||
// With a word in the source font after it, the line is still written in both.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " ab cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab cab", "F2")]);
|
||||
// The spaces the subset has no glyph for are moves, each one kept: the leading one before
|
||||
// the first word, two between words as two.
|
||||
let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc));
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc));
|
||||
// Before a substitute word, the leading spaces are the substitute's to show, all of them.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("( cab) Tj"), "{content}");
|
||||
}
|
||||
|
||||
/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier
|
||||
/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A
|
||||
/// later replacement of one of those rows must not take the sequence's other rows with it.
|
||||
#[test]
|
||||
fn replacing_one_row_of_a_sequence_keeps_the_other_rows() {
|
||||
let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET";
|
||||
let rows = text::text_lines(&text_page(content), 0).unwrap();
|
||||
assert_eq!(rows.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab", "ba"]);
|
||||
for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] {
|
||||
let mut doc = text_page(content);
|
||||
text::replace_line(&mut doc, 0, row, text).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let after: Vec<String> = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect();
|
||||
assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run
|
||||
/// has the substitute's font in effect at its first operator. Replaced by text the row's own font
|
||||
/// shows, it must set that font again, or the new text is drawn in the substitute.
|
||||
#[test]
|
||||
fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() {
|
||||
// A real two-font line, starting with the substitute run, then a second row put inside its
|
||||
// sequence, as a paragraph rewrite that keeps the delimiters can leave it.
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, " cab ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}");
|
||||
let mut doc = doc;
|
||||
let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1);
|
||||
let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes()))));
|
||||
set_page_contents(&mut doc, stream, None);
|
||||
let rows = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("cab ab", "F2"), ("ba", "F2")]);
|
||||
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let after = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::<Vec<_>>(), [("ab", "F2"), ("ba", "F2")]);
|
||||
}
|
||||
|
||||
/// Review on #2955: a sequence holding one row can also hold that row's positioning and text
|
||||
/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the
|
||||
/// delimiters, fonts and text, not the `Tm` that places it.
|
||||
#[test]
|
||||
fn replacing_the_only_row_of_a_sequence_keeps_its_position() {
|
||||
let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET";
|
||||
let mut doc = text_page(content);
|
||||
let before = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(before.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["ab cab"]);
|
||||
text::replace_line(&mut doc, 0, 0, "ab").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}");
|
||||
let after = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(after.len(), 1, "{content}");
|
||||
let (was, now) = (before[0].rect, after[0].rect);
|
||||
assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
|
||||
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
//! dropped, and every positioning operator stays, so the lines after it keep their places
|
||||
//! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change).
|
||||
//!
|
||||
//! The new text uses the line's own font when every character has a code and a glyph in it.
|
||||
//! Otherwise the words the font can show keep it and only the others are set in Helvetica
|
||||
//! (WinAnsi), run by run, and the result says the font was substituted.
|
||||
//! The new text uses the line's own font when every character has a code and a glyph in it;
|
||||
//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted.
|
||||
//! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -133,9 +132,6 @@ pub struct TextLine {
|
||||
/// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none
|
||||
/// (#2529). Meaningless otherwise.
|
||||
soft_gap: usize,
|
||||
/// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in
|
||||
/// (see [`fallback::continues_font`]).
|
||||
edited: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::interp::{Shown, Ts};
|
||||
use super::interp::Ts;
|
||||
use super::lines::page_dict;
|
||||
use super::{
|
||||
CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse,
|
||||
@@ -148,23 +148,6 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option<std::col
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Whether run `s` may continue `line` font-wise: the same font, or both inside the `/PCEdLine`
|
||||
/// sequence of a replacement that set the words its font can't show in a substitute (#2378),
|
||||
/// which reads back as the one line it replaced, so the next edit replaces all of it.
|
||||
pub(super) fn continues_font(line: &TextLine, s: &Shown) -> bool {
|
||||
same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited)
|
||||
}
|
||||
|
||||
/// A line that began with a substitute run takes the source font of a later run, so the next
|
||||
/// replacement starts from the line's own font again.
|
||||
pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) {
|
||||
if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") {
|
||||
line.font = String::from_utf8_lossy(&s.font).into_owned();
|
||||
line.base_font = s.base_font.clone();
|
||||
line.face = s.face.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese
|
||||
/// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another
|
||||
/// resource for the same font (a writer may split one font into several subsets, #3661), or
|
||||
@@ -747,30 +730,6 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in
|
||||
/// order. A word is shown in one font, so a word with any character the font lacks goes to the
|
||||
/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are
|
||||
/// in, since [`source_show`] draws spaces the font lacks with a displacement.
|
||||
pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> {
|
||||
let mut runs: Vec<(bool, String)> = Vec::new();
|
||||
// Spaces before the first word go with it: on their own they would make a run of the line's
|
||||
// font with nothing to show (review on #2955).
|
||||
let mut lead = String::new();
|
||||
for (i, word) in text.split(' ').enumerate() {
|
||||
let piece = if i == 0 { word.to_string() } else { format!(" {word}") };
|
||||
if runs.is_empty() && word.is_empty() {
|
||||
lead.push_str(&piece);
|
||||
continue;
|
||||
}
|
||||
let own = word.is_empty() || metrics.encode(word).is_some();
|
||||
match runs.last_mut() {
|
||||
Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece),
|
||||
_ => runs.push((own, std::mem::take(&mut lead) + &piece)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -359,7 +359,6 @@ pub(super) struct Shown {
|
||||
/// `/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,
|
||||
pub(super) edited: Option<usize>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
@@ -541,8 +540,6 @@ type Invocation = (usize, Vec<u8>, Ts);
|
||||
struct MarkedContent {
|
||||
regions: Vec<Option<usize>>,
|
||||
layers: Vec<Option<Vec<u8>>>,
|
||||
// The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last.
|
||||
edited: Vec<Option<usize>>,
|
||||
actual_text: Vec<Option<(usize, usize, String)>>,
|
||||
}
|
||||
|
||||
@@ -556,7 +553,6 @@ impl MarkedContent {
|
||||
let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice());
|
||||
self.layers
|
||||
.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec)));
|
||||
self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() });
|
||||
// The property list is inline or named in the resources' /Properties (§14.6.2).
|
||||
let list = logical_text.and_then(|properties| match op.operands.get(1) {
|
||||
Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)),
|
||||
@@ -572,7 +568,6 @@ impl MarkedContent {
|
||||
fn end(&mut self, index: usize, shown: &mut Vec<Shown>) {
|
||||
self.regions.pop();
|
||||
self.layers.pop();
|
||||
self.edited.pop();
|
||||
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
|
||||
let tail = shown.split_off(start.min(shown.len()));
|
||||
if tail.iter().any(|s| s.glyph_count > 0) {
|
||||
@@ -831,7 +826,6 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
|
||||
soft_break: state.soft_break,
|
||||
hard_break: state.hard_break,
|
||||
soft_gap: state.soft_gap,
|
||||
edited: marked.edited.last().copied().flatten(),
|
||||
});
|
||||
carry.gap_space = gap_space;
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::fallback::{adopt_source_font, continues_font, same_paint};
|
||||
use super::fallback::{same_font, same_paint};
|
||||
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
|
||||
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
|
||||
|
||||
@@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
piece == si
|
||||
&& lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01
|
||||
@@ -374,7 +374,6 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
if l.unreadable_mode.is_none() {
|
||||
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
|
||||
}
|
||||
adopt_source_font(l, &s);
|
||||
// Spaces may use a separate subset font; they do not change the visible style.
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
@@ -402,7 +401,6 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
edited: s.edited,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -463,7 +461,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
let bt = pen.bt;
|
||||
lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref())))
|
||||
&& (s.text.trim().is_empty()
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01
|
||||
@@ -494,7 +492,6 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
if l.unreadable_mode.is_none() {
|
||||
l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned);
|
||||
}
|
||||
adopt_source_font(l, &s);
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
@@ -521,7 +518,6 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
soft_gap: s.soft_gap,
|
||||
edited: s.edited,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
|
||||
use super::fallback::{
|
||||
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font,
|
||||
source_encodes, source_family, source_program_completes, source_runs, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
};
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
@@ -110,24 +110,10 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(format!("{} Type3", fallback.family));
|
||||
} else {
|
||||
// The words the line's font can show keep it; only the others are set in the
|
||||
// substitute (#2378): a subset without `V` or `ü` no longer turns the whole line
|
||||
// into Helvetica. Reordered text (right to left, Indic) keeps one run as before.
|
||||
let mixed = font
|
||||
.as_ref()
|
||||
.filter(|_| !visual_indic && fallback_shown == text)
|
||||
.map(|m| source_runs(m, &text))
|
||||
.filter(|runs| runs.iter().any(|(own, _)| *own));
|
||||
// What the substitute has to show: only the runs the line's font can't.
|
||||
let for_substitute: String = match &mixed {
|
||||
Some(runs) => runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(),
|
||||
None => fallback_shown.clone(),
|
||||
};
|
||||
let win = pdfcraft_fonts::win_ansi(&fallback_shown);
|
||||
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
|
||||
let checked = pdfcraft_fonts::win_ansi(&for_substitute);
|
||||
let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
|
||||
}
|
||||
// The size in text space: the current Tf's size.
|
||||
@@ -135,36 +121,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
let family = source_family(&target.base_font);
|
||||
let base = family.base_font(target.bold, target.italic);
|
||||
let substitute_name = format!("PCEd{}", base.replace('-', ""));
|
||||
match mixed {
|
||||
Some(runs) => {
|
||||
let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m));
|
||||
// Marked as one line, so it reads back as one (see `continues_font`).
|
||||
replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")]));
|
||||
for (own, run) in runs {
|
||||
if own {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
if let Some(m) = font.as_ref() {
|
||||
replacement.extend(source_show(
|
||||
m,
|
||||
&run,
|
||||
target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size),
|
||||
space,
|
||||
target.origin.state.char_spacing,
|
||||
target.origin.state.word_spacing,
|
||||
)?);
|
||||
}
|
||||
} else {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
None => {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(base.to_string());
|
||||
let mut f = Dict::new();
|
||||
@@ -177,43 +135,6 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
}
|
||||
}
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go.
|
||||
// A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own,
|
||||
// with its fonts and the moves standing in for spaces the source font lacks: those go
|
||||
// with the line, or each edit would nest one more (review on #2955). The line then starts in
|
||||
// its own font, set again where the sequence was.
|
||||
if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) {
|
||||
// Only when the sequence holds nothing but this line: a paragraph rewrite keeps the
|
||||
// delimiters, so other rows can be inside it (review on #2955). Then the sequence stays
|
||||
// as it is, since it is also what reads its own two-font line back as one, and only
|
||||
// this line's operators go.
|
||||
let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o)));
|
||||
// Either way the replacement starts in the line's own font: inside the sequence the font
|
||||
// in effect at the line's first operator can be the substitute's, which a replacement
|
||||
// the line's font shows by itself would otherwise inherit (review on #2955).
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
if !others_inside {
|
||||
// What goes is what the replacement stands in for: the marked-content delimiters
|
||||
// (nested ones too, which come in pairs), the fonts and the text-showing operators
|
||||
// (the line's runs and the moves between them). A paragraph rewrite can also write a
|
||||
// row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET`
|
||||
// inside kept delimiters; those stay, or the replacement loses its place and the
|
||||
// content its balance (review on #2955).
|
||||
drops.extend(
|
||||
(open..=close)
|
||||
.filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))),
|
||||
);
|
||||
let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size));
|
||||
// The same font set just before the sequence (by the last edit) is set again here.
|
||||
if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) {
|
||||
drops.insert(i);
|
||||
}
|
||||
// And set back after it: the replacement ends in that font too.
|
||||
if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) {
|
||||
drops.insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
|
||||
let contents = rewritten_contents(doc, &streams, new_data)?;
|
||||
let page_ref = p.obj;
|
||||
@@ -229,24 +150,6 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
Ok(LineEdit { substituted })
|
||||
}
|
||||
|
||||
/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins.
|
||||
fn matching_emc(ops: &[Op], open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (i, op) in ops.iter().enumerate().skip(open) {
|
||||
match op.op.as_slice() {
|
||||
b"BMC" | b"BDC" => depth = depth.saturating_add(1),
|
||||
b"EMC" => {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The size of the last `Tf` before operator `at`.
|
||||
fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
|
||||
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
|
||||
|
||||
@@ -138,7 +138,7 @@ mod tests {
|
||||
space_before.push(gap);
|
||||
(x, gap) = (x + 6.0, false);
|
||||
}
|
||||
PageText { glyphs, line_of, space_before }
|
||||
PageText { glyphs, line_of, space_before, truncated: false }
|
||||
}
|
||||
|
||||
fn uris(s: &str) -> Vec<String> {
|
||||
|
||||
@@ -1714,7 +1714,7 @@ fn unsupported_first_validation_call_preserves_custom_script_dispatch() {
|
||||
assert_eq!(script, JS);
|
||||
assert_eq!(target.name, "amount");
|
||||
self.calls += 1;
|
||||
ScriptResult { rc: false, value: value.into(), changes: Vec::new(), message: Some("custom validation ran".into()) }
|
||||
ScriptResult { rc: false, value: value.into(), value_set: false, changes: Vec::new(), message: Some("custom validation ran".into()) }
|
||||
}
|
||||
}
|
||||
let mut doc = one_page();
|
||||
|
||||
@@ -161,9 +161,6 @@ impl PdfCraftApp {
|
||||
v["pinned_tools"] = serde_json::json!(self.pinned_tools);
|
||||
// Set outside json! above, which is at serde_json's macro recursion limit.
|
||||
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
|
||||
// The Print dialog's paper list and each printer's paper (print.page-setup).
|
||||
v["print_custom_papers"] = serde_json::json!(self.print_draft.custom_papers);
|
||||
v["print_paper_by_printer"] = serde_json::json!(self.print_draft.remembered_paper);
|
||||
v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
|
||||
// Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and
|
||||
// settings written then would turn this off unasked.
|
||||
@@ -173,10 +170,8 @@ impl PdfCraftApp {
|
||||
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
|
||||
v["text_links"] = serde_json::Value::Bool(self.text_links);
|
||||
v["organize_zoom"] = serde_json::json!(self.organize_zoom);
|
||||
// Canonical names survive changes to the order of PAPERS and do not depend on UI language;
|
||||
// a custom paper is stored by the name it was given.
|
||||
let d = &self.print_draft;
|
||||
v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name()));
|
||||
// Canonical names survive changes to the order of PAPERS and do not depend on UI language.
|
||||
v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name));
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
@@ -297,16 +292,11 @@ impl PdfCraftApp {
|
||||
if let Some(on) = v["always_default_view"].as_bool() {
|
||||
self.always_default_view = on;
|
||||
}
|
||||
// The Print dialog's paper list and each printer's paper; settings are untrusted, so each
|
||||
// custom paper is checked as if it were typed.
|
||||
if v.get("print_custom_papers").is_some() {
|
||||
self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]);
|
||||
}
|
||||
if v.get("print_paper_by_printer").is_some() {
|
||||
self.print_draft.remembered_paper = print_paper::read_remembered(&v["print_paper_by_printer"]);
|
||||
}
|
||||
// After the custom papers, so a custom paper chosen last time is found by its name.
|
||||
if let Some(paper) = v.get("print_paper").and_then(serde_json::Value::as_str).and_then(|name| self.print_draft.paper_index(name)) {
|
||||
if let Some(paper) = v
|
||||
.get("print_paper")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name))
|
||||
{
|
||||
self.print_draft.paper = paper;
|
||||
}
|
||||
if let Some(on) = v["highlight_fields"].as_bool() {
|
||||
|
||||
@@ -197,7 +197,6 @@ use workflows::ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use workflows::ocr_web;
|
||||
use workflows::optimize_ui;
|
||||
use workflows::print_paper;
|
||||
use workflows::print_ui;
|
||||
use workflows::scan_clean_ui;
|
||||
use workflows::web_print;
|
||||
|
||||
@@ -920,7 +920,7 @@ mod tests {
|
||||
let widget = Rect::from_min_size(pos2(30.0, 40.0), vec2(20.0, 15.0));
|
||||
let size = vec2(24.0, 14.0);
|
||||
let original = tab_badge_rect(widget, page, size).unwrap();
|
||||
let moved = dragged(widget, Grab::Move, vec2(12.0, 8.0), false);
|
||||
let moved = dragged(widget, Grab::Move(None), vec2(12.0, 8.0), false);
|
||||
assert_eq!(tab_badge_rect(moved, page, size).unwrap().min, original.min + vec2(12.0, 8.0));
|
||||
|
||||
let near_edge = Rect::from_min_size(pos2(100.0, 95.0), vec2(8.0, 8.0));
|
||||
|
||||
@@ -15,7 +15,6 @@ pub(super) mod ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) mod ocr_web;
|
||||
pub(super) mod optimize_ui;
|
||||
pub(super) mod print_paper;
|
||||
pub(super) mod print_ui;
|
||||
pub(super) mod scan_clean_ui;
|
||||
pub(super) mod web_print;
|
||||
@@ -1,224 +0,0 @@
|
||||
//! The Print dialog's paper list and the paper each printer is remembered with (print.page-setup):
|
||||
//! paper sizes of a person's own, added with Custom… beside Paper and kept with the settings, and
|
||||
//! for each printer the paper chosen when "Remember this paper for …" was ticked at Print time.
|
||||
//! Two-sided, grayscale, collate, sizing and comments & forms are left to the dialog itself.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use pdfcraft_engine::print::PAPERS;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::print_ui::PrintDraft;
|
||||
|
||||
/// The largest side a custom paper can have, in points: PDF's own page limit (200 inches).
|
||||
pub const MAX_SIDE: f64 = 14_400.0;
|
||||
/// The smallest side, in points (one inch).
|
||||
pub const MIN_SIDE: f64 = 72.0;
|
||||
/// How many custom papers the settings keep.
|
||||
pub const MAX_CUSTOM: usize = 100;
|
||||
/// The longest name a custom paper keeps, in characters.
|
||||
const MAX_NAME: usize = 64;
|
||||
|
||||
/// A paper size added in the Print dialog (points, portrait).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CustomPaper {
|
||||
pub name: String,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl CustomPaper {
|
||||
/// A custom paper from a name and two sides in points, portrait; None when the name is empty
|
||||
/// or a side is outside [`MIN_SIDE`]..=[`MAX_SIDE`], or a standard paper already has the name
|
||||
/// (any case), which would otherwise be found first when the paper is looked up by name.
|
||||
pub fn new(name: &str, a: f64, b: f64) -> Option<Self> {
|
||||
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
|
||||
let fits = |v: f64| v.is_finite() && (MIN_SIDE..=MAX_SIDE).contains(&v);
|
||||
let standard = PAPERS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&name));
|
||||
(!name.is_empty() && !standard && fits(a) && fits(b)).then(|| Self { name, width: a.min(b), height: a.max(b) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The paper remembered for each printer, by the printer's name ("" is Save as PDF).
|
||||
pub type RememberedPaper = BTreeMap<String, String>;
|
||||
|
||||
/// Custom papers read back from the settings, which are untrusted: each one is checked as if it
|
||||
/// were typed, a name already taken keeps its first size, and at most [`MAX_CUSTOM`] are kept.
|
||||
pub fn read_custom(value: &serde_json::Value) -> Vec<CustomPaper> {
|
||||
let Some(list) = value.as_array() else { return Vec::new() };
|
||||
let mut out: Vec<CustomPaper> = Vec::new();
|
||||
for item in list {
|
||||
let (Some(name), Some(w), Some(h)) = (item["name"].as_str(), item["width"].as_f64(), item["height"].as_f64()) else { continue };
|
||||
if let Some(paper) = CustomPaper::new(name, w, h)
|
||||
&& !out.iter().any(|p| p.name == paper.name)
|
||||
&& out.len() < MAX_CUSTOM
|
||||
{
|
||||
out.push(paper);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The remembered papers read back from the settings: printer name to paper name, strings only.
|
||||
pub fn read_remembered(value: &serde_json::Value) -> RememberedPaper {
|
||||
let Some(map) = value.as_object() else { return RememberedPaper::new() };
|
||||
map.iter().filter_map(|(printer, paper)| paper.as_str().map(|p| (printer.clone(), p.to_string()))).collect()
|
||||
}
|
||||
|
||||
impl PrintDraft {
|
||||
/// The paper list: the standard sizes, then the custom ones.
|
||||
pub fn paper_count(&self) -> usize {
|
||||
PAPERS.len() + self.custom_papers.len()
|
||||
}
|
||||
|
||||
/// The chosen paper's size (points, portrait).
|
||||
pub fn paper_size(&self) -> (f64, f64) {
|
||||
match self.paper.checked_sub(PAPERS.len()) {
|
||||
None => PAPERS[self.paper.min(PAPERS.len() - 1)].1,
|
||||
Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen paper's name (a standard name, or the custom paper's).
|
||||
pub fn paper_name(&self) -> String {
|
||||
match self.paper.checked_sub(PAPERS.len()) {
|
||||
None => PAPERS[self.paper.min(PAPERS.len() - 1)].0.to_string(),
|
||||
Some(c) => self.custom_papers.get(c).map_or_else(|| PAPERS[0].0.to_string(), |p| p.name.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the paper called `name`, standard or custom.
|
||||
pub fn paper_index(&self, name: &str) -> Option<usize> {
|
||||
PAPERS.iter().position(|(n, _)| *n == name).or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| PAPERS.len() + i))
|
||||
}
|
||||
|
||||
/// True when the chosen paper is a custom one (which can be removed).
|
||||
pub fn paper_is_custom(&self) -> bool {
|
||||
self.paper >= PAPERS.len()
|
||||
}
|
||||
|
||||
/// Add (or replace, by name) a custom paper and choose it.
|
||||
pub fn add_custom_paper(&mut self, paper: CustomPaper) {
|
||||
self.custom_papers.retain(|p| p.name != paper.name);
|
||||
if self.custom_papers.len() >= MAX_CUSTOM {
|
||||
self.custom_papers.remove(0);
|
||||
}
|
||||
self.custom_papers.push(paper);
|
||||
self.paper = PAPERS.len() + self.custom_papers.len() - 1;
|
||||
}
|
||||
|
||||
/// Take the chosen custom paper off the list; the dialog goes back to the first size.
|
||||
pub fn remove_custom_paper(&mut self) {
|
||||
if let Some(i) = self.paper.checked_sub(PAPERS.len())
|
||||
&& i < self.custom_papers.len()
|
||||
{
|
||||
self.custom_papers.remove(i);
|
||||
self.paper = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The key the printer's paper is remembered under ("" is Save as PDF).
|
||||
fn printer_key(&self) -> String {
|
||||
self.printer.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Start from the paper remembered for the printer chosen, if one was and it is still on the
|
||||
/// list; the Remember box says whether one was.
|
||||
pub fn apply_remembered(&mut self) {
|
||||
let remembered = self.remembered_paper.get(&self.printer_key()).cloned();
|
||||
self.remember_paper = remembered.is_some();
|
||||
if let Some(i) = remembered.and_then(|name| self.paper_index(&name)) {
|
||||
self.paper = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// At Print time: keep the paper for the printer when the box is ticked, forget it when not.
|
||||
pub fn remember_now(&mut self) {
|
||||
let key = self.printer_key();
|
||||
if self.remember_paper {
|
||||
let name = self.paper_name();
|
||||
self.remembered_paper.insert(key, name);
|
||||
} else {
|
||||
self.remembered_paper.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_custom_paper_is_portrait_named_and_within_pdfs_limits() {
|
||||
let p = CustomPaper::new(" Legal, tray 2 ", 1008.0, 612.0).unwrap();
|
||||
assert_eq!((p.name.as_str(), p.width, p.height), ("Legal, tray 2", 612.0, 1008.0));
|
||||
assert!(CustomPaper::new("", 612.0, 792.0).is_none());
|
||||
assert!(CustomPaper::new("\u{1}", 612.0, 792.0).is_none());
|
||||
assert!(CustomPaper::new("Tiny", 10.0, 792.0).is_none());
|
||||
assert!(CustomPaper::new("Huge", 612.0, 20_000.0).is_none());
|
||||
assert!(CustomPaper::new("NaN", f64::NAN, 792.0).is_none());
|
||||
assert_eq!(CustomPaper::new(&"x".repeat(500), 612.0, 792.0).unwrap().name.chars().count(), MAX_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_read_as_untrusted() {
|
||||
let v = serde_json::json!([
|
||||
{ "name": "Label", "width": 288.0, "height": 432.0 },
|
||||
{ "name": "Label", "width": 300.0, "height": 400.0 },
|
||||
{ "name": "Bad", "width": -1.0, "height": 432.0 },
|
||||
{ "name": 5, "width": 288.0, "height": 432.0 },
|
||||
"nonsense",
|
||||
]);
|
||||
assert_eq!(read_custom(&v), vec![CustomPaper { name: "Label".into(), width: 288.0, height: 432.0 }]);
|
||||
assert!(read_custom(&serde_json::json!(5)).is_empty());
|
||||
let many: Vec<_> = (0..300).map(|i| serde_json::json!({ "name": format!("P{i}"), "width": 300.0, "height": 400.0 })).collect();
|
||||
assert_eq!(read_custom(&serde_json::Value::Array(many)).len(), MAX_CUSTOM);
|
||||
let r = read_remembered(&serde_json::json!({ "Office": "A4", "": "Label", "Odd": 3 }));
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r.get("").map(String::as_str), Some("Label"));
|
||||
assert!(read_remembered(&serde_json::json!([1])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paper_list_is_the_standard_sizes_then_the_custom_ones() {
|
||||
let mut d = PrintDraft::default();
|
||||
assert_eq!(d.paper_count(), PAPERS.len());
|
||||
d.add_custom_paper(CustomPaper::new("Label", 288.0, 432.0).unwrap());
|
||||
assert_eq!((d.paper, d.paper_name().as_str(), d.paper_size()), (PAPERS.len(), "Label", (288.0, 432.0)));
|
||||
assert_eq!(d.paper_index("A4"), PAPERS.iter().position(|(n, _)| *n == "A4"));
|
||||
assert_eq!(d.paper_index("Label"), Some(PAPERS.len()));
|
||||
// The same name replaces the size; removing goes back to the first standard size.
|
||||
d.add_custom_paper(CustomPaper::new("Label", 300.0, 400.0).unwrap());
|
||||
assert_eq!((d.paper_count(), d.paper_size()), (PAPERS.len() + 1, (300.0, 400.0)));
|
||||
d.remove_custom_paper();
|
||||
assert_eq!((d.paper_count(), d.paper), (PAPERS.len(), 0));
|
||||
// A stale index reads as the first size, never out of bounds.
|
||||
d.paper = PAPERS.len() + 7;
|
||||
assert_eq!(d.paper_size(), PAPERS[0].1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paper_is_remembered_per_printer_only_when_asked() {
|
||||
let mut d = PrintDraft::default();
|
||||
let a4 = d.paper_index("A4").unwrap();
|
||||
d.printer = Some("Office".into());
|
||||
d.paper = a4;
|
||||
d.remember_paper = true;
|
||||
d.remember_now();
|
||||
d.printer = Some("Plotter".into());
|
||||
d.paper = 0;
|
||||
d.apply_remembered();
|
||||
assert_eq!((d.paper, d.remember_paper), (0, false), "another printer keeps the dialog's paper");
|
||||
d.printer = Some("Office".into());
|
||||
d.apply_remembered();
|
||||
assert_eq!((d.paper, d.remember_paper), (a4, true));
|
||||
d.remember_paper = false;
|
||||
d.remember_now();
|
||||
assert!(d.remembered_paper.is_empty());
|
||||
// A remembered custom paper that was removed since leaves the paper alone.
|
||||
d.remembered_paper.insert("Office".into(), "Gone".into());
|
||||
d.paper = 0;
|
||||
d.apply_remembered();
|
||||
assert_eq!((d.paper, d.remember_paper), (0, true));
|
||||
}
|
||||
}
|
||||
@@ -171,10 +171,8 @@ impl PdfCraftApp {
|
||||
sheet: 0,
|
||||
selected,
|
||||
which,
|
||||
custom_new: None,
|
||||
..keep
|
||||
};
|
||||
self.print_draft.apply_remembered();
|
||||
self.dialog = Some(crate::Dialog::Print);
|
||||
}
|
||||
|
||||
@@ -316,13 +314,7 @@ impl PdfCraftApp {
|
||||
// A picked printer that is no longer listed falls back to the default too.
|
||||
if !d.printer_picked || !offered(d.printer.as_deref(), &printers) {
|
||||
d.printer_picked = false;
|
||||
// Only a different printer starts over with its remembered paper; the
|
||||
// same one keeps the paper and the Remember box as they were left.
|
||||
let default = default_printer(&printers);
|
||||
if default != d.printer {
|
||||
d.printer = default;
|
||||
d.apply_remembered();
|
||||
}
|
||||
d.printer = default_printer(&printers);
|
||||
}
|
||||
d.printers = printers;
|
||||
d.printers_loading = false;
|
||||
|
||||
@@ -158,7 +158,6 @@ pub(crate) fn body(
|
||||
d.printer_picked = true;
|
||||
d.show_driver_options = false;
|
||||
d.driver_error = None;
|
||||
d.apply_remembered();
|
||||
}
|
||||
ui.end_row();
|
||||
// The spooler answered with no printer at all (#160): say so, rather than
|
||||
@@ -203,35 +202,15 @@ pub(crate) fn body(
|
||||
ui.end_row();
|
||||
}
|
||||
let paper_label = ui.label(tl!("Paper:"));
|
||||
d.paper = d.paper.min(d.paper_count().saturating_sub(1));
|
||||
let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0);
|
||||
let before = d.paper;
|
||||
ui.horizontal(|ui| {
|
||||
// A standard size's name is translated; a custom paper keeps the name it was given.
|
||||
let shown = if d.paper_is_custom() { d.paper_name() } else { tl!(d.paper_name().as_str()).to_string() };
|
||||
craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(260.0).show_ui(ui, |ui| {
|
||||
for (i, (name, _)) in PAPERS.iter().enumerate() {
|
||||
ui.selectable_value(&mut d.paper, i, tl!(name));
|
||||
}
|
||||
if !d.custom_papers.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
for (i, p) in d.custom_papers.iter().enumerate() {
|
||||
let label = format!("{} ({})", p.name, d.units.size(p.width, p.height));
|
||||
ui.selectable_value(&mut d.paper, PAPERS.len() + i, label);
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(paper_label.id);
|
||||
if ui.button(tl!("Custom…")).on_hover_text(tl!("Add a paper size of your own to the list")).clicked() {
|
||||
d.custom_new = match d.custom_new {
|
||||
Some(_) => None,
|
||||
None => Some((String::new(), String::new(), String::new())),
|
||||
};
|
||||
craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| {
|
||||
for (i, (name, _)) in PAPERS.iter().enumerate() {
|
||||
ui.selectable_value(&mut d.paper, i, tl!(name));
|
||||
}
|
||||
if d.paper_is_custom() && ui.button(tl!("Remove")).on_hover_text(tl!("Take this paper size off the list")).clicked() {
|
||||
d.remove_custom_paper();
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(paper_label.id);
|
||||
// Label stock gets the label layout (cut, turned upright, fitted).
|
||||
if d.paper != before && d.paper == print::LABEL_PAPER {
|
||||
d.paper_before_label = Some(before);
|
||||
@@ -239,11 +218,6 @@ pub(crate) fn body(
|
||||
d.sheet = 0;
|
||||
}
|
||||
ui.end_row();
|
||||
if d.custom_new.is_some() {
|
||||
ui.label("");
|
||||
custom_paper_row(ui, d, t);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
// Windows says which drivers can't print two-sided as they're asked; they keep their
|
||||
// own setting, even when Two-sided is Off.
|
||||
@@ -585,11 +559,6 @@ pub(crate) fn body(
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let (mut go, mut cancel) = (false, false);
|
||||
// The paper is kept for this printer (or Save as PDF) when this is ticked at Print time. On its
|
||||
// own row above the buttons: sharing their row moved them while the dialog settled.
|
||||
let for_whom = d.printer.as_deref().map_or_else(|| tl!("Save as PDF").to_string(), printer_label);
|
||||
ui.add(widgets::checkbox(&mut d.remember_paper, crate::i18n::fmt(tl!("Remember this paper for {printer}"), &[("printer", &for_whom)])));
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let looking = still_looking(d);
|
||||
let label = if d.printer.is_some() || looking { tl!("Print") } else { tl!("Save as PDF") };
|
||||
@@ -600,35 +569,5 @@ pub(crate) fn body(
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
if go {
|
||||
d.remember_now();
|
||||
}
|
||||
(go, cancel)
|
||||
}
|
||||
|
||||
/// The row that adds a custom paper: name, width and height in the preferences' unit.
|
||||
fn custom_paper_row(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) {
|
||||
let unit = d.units;
|
||||
let Some((name, width, height)) = d.custom_new.as_mut() else { return };
|
||||
let side = |s: &str| s.trim().replace(',', ".").parse::<f64>().ok().map(|v| v / unit.per_point());
|
||||
let paper = match (side(width), side(height)) {
|
||||
(Some(w), Some(h)) => crate::print_paper::CustomPaper::new(name, w, h),
|
||||
_ => None,
|
||||
};
|
||||
let (mut add, mut cancel) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(name).hint_text(tl!("Name")).desired_width(110.0)).accessible_name(tl!("Name"));
|
||||
ui.add(egui::TextEdit::singleline(width).hint_text(tl!("Width")).desired_width(52.0)).accessible_name(tl!("Width"));
|
||||
ui.label("×");
|
||||
ui.add(egui::TextEdit::singleline(height).hint_text(tl!("Height")).desired_width(52.0)).accessible_name(tl!("Height"));
|
||||
ui.label(egui::RichText::new(unit.label()).color(t.text_muted));
|
||||
add = ui.add_enabled(paper.is_some(), egui::Button::new(tl!("Add"))).clicked();
|
||||
cancel = ui.button(tl!("Cancel")).clicked();
|
||||
});
|
||||
if add && let Some(paper) = paper {
|
||||
d.add_custom_paper(paper);
|
||||
d.custom_new = None;
|
||||
} else if cancel {
|
||||
d.custom_new = None;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool};
|
||||
use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Which {
|
||||
@@ -62,16 +62,7 @@ pub struct PrintDraft {
|
||||
/// Print as image (Acrobat's Advanced Print Setup) at `dpi`.
|
||||
pub as_image: bool,
|
||||
pub dpi: f64,
|
||||
/// Index into the paper list: the standard sizes, then `custom_papers`.
|
||||
pub paper: usize,
|
||||
/// Paper sizes added with Custom…, kept with the settings.
|
||||
pub custom_papers: Vec<crate::print_paper::CustomPaper>,
|
||||
/// The paper each printer is remembered with, kept with the settings.
|
||||
pub remembered_paper: crate::print_paper::RememberedPaper,
|
||||
/// "Remember this paper for …", applied at Print time.
|
||||
pub remember_paper: bool,
|
||||
/// The custom paper being added: name, width and height as typed (the preferences' unit).
|
||||
pub custom_new: Option<(String, String, String)>,
|
||||
/// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564).
|
||||
pub paper_before_label: Option<usize>,
|
||||
/// The sheet shown in the preview (0-based).
|
||||
@@ -135,10 +126,6 @@ impl Default for PrintDraft {
|
||||
as_image: false,
|
||||
dpi: 300.0,
|
||||
paper: 0,
|
||||
custom_papers: Vec::new(),
|
||||
remembered_paper: Default::default(),
|
||||
remember_paper: false,
|
||||
custom_new: None,
|
||||
paper_before_label: None,
|
||||
sheet: 0,
|
||||
current_page: 0,
|
||||
@@ -219,7 +206,7 @@ impl PrintDraft {
|
||||
};
|
||||
Ok(print::Settings {
|
||||
pages,
|
||||
paper: self.paper_size(),
|
||||
paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1,
|
||||
orientation: self.orientation,
|
||||
layout,
|
||||
content: self.content,
|
||||
|
||||
@@ -66,8 +66,6 @@ mod pointer;
|
||||
mod preferences_layout;
|
||||
mod prepare;
|
||||
mod print;
|
||||
mod print_custom_paper_names;
|
||||
mod print_late_printer_list;
|
||||
mod protect;
|
||||
mod reading_colors;
|
||||
mod recent_files;
|
||||
|
||||
@@ -918,98 +918,6 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() {
|
||||
std::fs::remove_file(out).unwrap();
|
||||
}
|
||||
|
||||
/// After the Print dialog opens: frames until its printer list has loaded (its arrival changes
|
||||
/// the dialog's rows, even an empty list, which adds the "no printers" line), then a few more so
|
||||
/// the dialog has settled where it stays. A click before then can land where a control was
|
||||
/// (windows-latest CI).
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..500 {
|
||||
h.run_steps(1);
|
||||
if !h.state().print_draft.printers_loading {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert!(!h.state().print_draft.printers_loading, "the printer list loaded");
|
||||
h.run_steps(8);
|
||||
}
|
||||
|
||||
/// Page setup: a paper size of one's own joins the Paper list (Custom…), the paper is kept for
|
||||
/// the printer when "Remember this paper for …" is ticked at Print time, starts the dialog next
|
||||
/// time, survives a restart with the settings, and is forgotten when unticked; Remove takes a
|
||||
/// custom size off the list.
|
||||
#[test]
|
||||
fn custom_papers_and_the_paper_remembered_for_a_printer() {
|
||||
let mut h = harness();
|
||||
// No printers here: Save as PDF is the dialog's printer, now and when it opens again.
|
||||
h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new));
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().print_draft.printer, None);
|
||||
h.get_by_label("Custom…").click();
|
||||
h.run_steps(2);
|
||||
// Typed in the preferences' unit: 4 × 6 inches.
|
||||
let per_point = h.state().print_draft.units.per_point();
|
||||
h.state_mut().print_draft.custom_new = Some(("Label 4×6".into(), format!("{}", 432.0 * per_point), format!("{}", 288.0 * per_point)));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Add").click();
|
||||
h.run_steps(2);
|
||||
{
|
||||
let d = &h.state().print_draft;
|
||||
assert!(d.paper_is_custom() && d.paper_name() == "Label 4×6", "{}", d.paper_name());
|
||||
let (w, l) = d.paper_size();
|
||||
assert!((w - 288.0).abs() < 0.01 && (l - 432.0).abs() < 0.01, "portrait: {w}×{l}");
|
||||
}
|
||||
// The custom row has closed: let the dialog settle at its new height before clicking.
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Remember this paper for Save as PDF").click();
|
||||
h.run_steps(1);
|
||||
assert!(h.state().print_draft.remember_paper, "ticked");
|
||||
let out = std::env::temp_dir().join(format!("pdfcraft-print-paper-test-{}.pdf", std::process::id()));
|
||||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Save as PDF").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
let bytes = std::fs::read(&out).expect("saved");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let sheet = pdfcraft_model::pages(&saved)[0].display_size(&saved);
|
||||
assert!((sheet.0.min(sheet.1) - 288.0).abs() < 0.5 && (sheet.0.max(sheet.1) - 432.0).abs() < 0.5, "the label paper: {sheet:?}");
|
||||
assert_eq!(h.state().print_draft.remembered_paper.get("").map(String::as_str), Some("Label 4×6"));
|
||||
// Next time the dialog starts with it.
|
||||
h.state_mut().print_draft.paper = 0;
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
settle(&mut h);
|
||||
assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Label 4×6", true));
|
||||
// And after a restart.
|
||||
let settings = h.state().persist();
|
||||
let mut fresh = PdfCraftApp::new();
|
||||
fresh.restore(&settings);
|
||||
assert_eq!(fresh.print_draft.paper_count(), h.state().print_draft.paper_count());
|
||||
assert_eq!(fresh.print_draft.remembered_paper, h.state().print_draft.remembered_paper);
|
||||
// Unticked at Print time, the paper is forgotten for Save as PDF.
|
||||
h.get_by_label("Remember this paper for Save as PDF").click();
|
||||
h.run_steps(1);
|
||||
assert!(!h.state().print_draft.remember_paper, "unticked");
|
||||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Save as PDF").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None, "saved");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
assert!(h.state().print_draft.remembered_paper.is_empty());
|
||||
// Remove takes the custom size off the list.
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
settle(&mut h);
|
||||
let custom = h.state().print_draft.paper_count() - 1;
|
||||
h.state_mut().print_draft.paper = custom;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Remove").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().print_draft.paper_count(), h.state().print_draft.paper), (custom, 0));
|
||||
}
|
||||
|
||||
/// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided
|
||||
/// while Booklet is chosen): the job flips on the short edge for both sides and prints
|
||||
/// one-sided for the front or back only, and the Two-sided choice is kept for other layouts.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//! A custom paper cannot take the name of a standard one: papers are looked up by name, so the
|
||||
//! standard size would be found first and a remembered custom paper would come back as it.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_paper_cannot_take_a_standard_name() {
|
||||
let mut h = harness();
|
||||
// No printers here: the list is empty as soon as it is asked for.
|
||||
h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new));
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
for _ in 0..500 {
|
||||
h.run_steps(1);
|
||||
if !h.state().print_draft.printers_loading {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert!(!h.state().print_draft.printers_loading, "the printer list loaded");
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Custom…").click();
|
||||
h.run_steps(2);
|
||||
let before = h.state().print_draft.paper_count();
|
||||
let per_point = h.state().print_draft.units.per_point();
|
||||
let (w, l) = (format!("{}", 288.0 * per_point), format!("{}", 432.0 * per_point));
|
||||
// The name of a standard paper, in either case, is refused: Add stays disabled.
|
||||
for name in ["A4", " a4 "] {
|
||||
h.state_mut().print_draft.custom_new = Some((name.into(), w.clone(), l.clone()));
|
||||
h.run_steps(3);
|
||||
assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "{name:?} is a standard paper's name");
|
||||
assert_eq!(h.state().print_draft.paper_count(), before);
|
||||
}
|
||||
// Any other name is accepted by the same row.
|
||||
h.state_mut().print_draft.custom_new = Some(("A4 label".into(), w.clone(), l.clone()));
|
||||
h.run_steps(3);
|
||||
assert!(!h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled());
|
||||
|
||||
// Settings read back are checked the same way: the custom "A4" is dropped, the rest kept.
|
||||
let paper = |name: &str| serde_json::json!({ "name": name, "width": 288.0, "height": 432.0 });
|
||||
let settings = serde_json::json!({
|
||||
"print_custom_papers": [paper("A4"), paper("Label"), paper("us letter")],
|
||||
"print_paper_by_printer": { "": "A4" },
|
||||
});
|
||||
let mut fresh = PdfCraftApp::new();
|
||||
fresh.restore(&settings.to_string());
|
||||
let names: Vec<&str> = fresh.print_draft.custom_papers.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["Label"]);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! A printer list that arrives late must not undo what was picked while it was being read, when
|
||||
//! the printer it names is the one the dialog already has.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_engine::print::spool::Printer;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn wait_for_list(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(!h.state().print_draft.printers_loading, "the printer list loaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_list_keeps_the_paper_picked_meanwhile() {
|
||||
let mut h = harness();
|
||||
// The source waits for the test to let it answer, once the first list is in.
|
||||
let gate = Arc::new(Mutex::new(()));
|
||||
let wait = gate.clone();
|
||||
h.state_mut().printer_source = Some(Arc::new(move || {
|
||||
let _open = wait.lock();
|
||||
vec![Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }]
|
||||
}));
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
wait_for_list(&mut h);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"));
|
||||
|
||||
// "Office" is remembered with Tabloid (the third size).
|
||||
h.state_mut().print_draft.remembered_paper.insert("Office".into(), "Tabloid".into());
|
||||
h.state_mut().dialog = None;
|
||||
let hold = gate.lock().unwrap();
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().print_draft.printers_loading, "the fresh list is still being read");
|
||||
assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "last time's list shows at once");
|
||||
assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Tabloid", true));
|
||||
|
||||
// Meanwhile the person picks another paper and unticks Remember.
|
||||
{
|
||||
let d = &mut h.state_mut().print_draft;
|
||||
d.paper = 0;
|
||||
d.remember_paper = false;
|
||||
}
|
||||
drop(hold);
|
||||
wait_for_list(&mut h);
|
||||
h.run_steps(2);
|
||||
let d = &h.state().print_draft;
|
||||
assert_eq!(d.printer.as_deref(), Some("Office"), "the same printer");
|
||||
assert_eq!((d.paper, d.remember_paper), (0, false), "the paper and the Remember box are as they were left");
|
||||
}
|
||||
@@ -1907,8 +1907,8 @@ milestone = "M7"
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
|
||||
evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/testsLine truncated
|
||||
notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped; replacing one line keeps the source font for every word it can show and sets only the other words in Helvetica (#2378), while a paragraph rewrite still substitutes the whole paragraph for a Latin character its subset lacks); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
|
||||
evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/testsLine truncated
|
||||
notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.text-reflow"
|
||||
@@ -8390,11 +8390,11 @@ title = "Page setup"
|
||||
area = "L"
|
||||
tier = "P1"
|
||||
milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/ui-egui/tests/it/print.rs::custom_papers_and_the_paper_remembered_for_a_printer", "crates/ui-egui/src/workflows/print_paper.rs::the_paper_is_remembered_per_printer_only_when_asked", "crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted", "crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own"]
|
||||
notes = "In the Print dialog, as the rest of printing is (no separate Page Setup window): Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable; 'Remember this paper for <printer>' at Print time keeps the paper for that printer (or Save as PDF) and the dialog starts with it. doc_print takes sizes such as 8.5x14in or 210x297mm. Missing: margins, and the dialog's other choices (orientation, two-sided, sizing and so on) remembered per printer."
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
id = "print.print-ready-pdf"
|
||||
|
||||
Reference in new issue
Block a user