mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b1d4cccc | ||
|
|
04217419b6 | ||
|
|
8ed09d6aee | ||
|
|
4afff61f10 | ||
|
|
2ba57408e3 | ||
|
|
e76293a564 | ||
|
|
ead0ce437d | ||
|
|
e0d8312fbe | ||
|
|
26f61702d3 | ||
|
|
523e319424 | ||
|
|
735c2a5382 | ||
|
|
e7c1410103 | ||
|
|
d6d19d2517 | ||
|
|
f75b1cc4ce | ||
|
|
b909e7d6b9 | ||
|
|
4f0fee6fc6 |
@@ -1,6 +1,7 @@
|
||||
# WordCraft roadmap
|
||||
|
||||
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
|
||||
> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged.
|
||||
|
||||
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
|
||||
|
||||
@@ -170,6 +171,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged.
|
||||
- 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged.
|
||||
- 2026-11-07: Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area with margins for system panels, preventing new windows from exceeding screen width on high-DPI scaled displays (#5871).
|
||||
- 2026-11-07: A floating anchor after a page break stays with text on the far side of the same wrapped row (Leaf #107); marker-only cover anchors retain their rule. Picture spacing, drop position, Undo/Redo and DOCX regressions.
|
||||
- 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged.
|
||||
- 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged.
|
||||
- 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged.
|
||||
@@ -254,6 +256,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-03: Security: DOCX import looks relationships up by id through an index built once per part, the first relationship with an id still winning, instead of scanning every relationship for each hyperlink, picture or embedded part that names one. Readiness unchanged.
|
||||
- 2026-11-03: Security: DOCX import no longer carries a file's part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots` and the like), which a save read back as that bookkeeping, adding relationships the file chose. Readiness unchanged.
|
||||
- 2026-11-03: Security: LaTeX import parses at most 63 cells of a `tabular` row (what a table row keeps), so a very wide row can no longer widen the multirow tracking that every later row walks. Readiness unchanged.
|
||||
- 2026-11-03: Security: XLSX and ODS import (mail merge recipient lists) keep each cell's character count as text is added, instead of counting the whole cell again for every piece of text, so a full cell followed by many small text events no longer costs its length each time. Readiness unchanged.
|
||||
- 2026-11-03: Security: exported hyperlinks (ODT, LaTeX, HTML, Markdown, RTF) holding a control character or noncharacter that the writer drops are written as plain text, so `file\u{85}:///…` can't pass the link check and come out as a `file:` link. Readiness unchanged.
|
||||
- 2026-11-03: Security: pictures are measured, and checked for transparency for tight text wrapping, from a PNG's chunk headers, instead of through the decoder, whose header read inflates an embedded ICC profile of up to 512 MiB for every picture it only measures. Readiness unchanged.
|
||||
- 2026-11-03: Security: saving an embedded object (a native document's kept markup included) collects the relationship ids its markup uses in a set and checks them against the writable ones in one pass, instead of scanning every id collected so far for each attribute. Readiness unchanged.
|
||||
- 2026-11-03: Security: the renderer leaves out a drawing path reaching more than a million device pixels (64 times the largest canvas) or of no finite size, which the rasteriser would otherwise flatten into points by the billion before clipping it. Readiness unchanged.
|
||||
- 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged.
|
||||
- 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged.
|
||||
- 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged.
|
||||
@@ -888,6 +895,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) |
|
||||
| 2026-11-06 | trivial | Progress log: line numbers take the document's "Line Number" style |
|
||||
| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) |
|
||||
| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Estimates unchanged. |
|
||||
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
|
||||
| 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) |
|
||||
| 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) |
|
||||
|
||||
@@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
// Exporters drop other control characters and noncharacters when they write the link
|
||||
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
|
||||
// stands and written as something else.
|
||||
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
|
||||
return false;
|
||||
}
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
@@ -200,6 +206,10 @@ mod tests {
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
// Characters the exporters drop on writing don't hide a scheme.
|
||||
"file\u{85}:///etc/passwd",
|
||||
"java\u{9F}script:alert(1)",
|
||||
"/\u{FFFE}/attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
|
||||
};
|
||||
self.embeds.markup_bytes = left;
|
||||
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
|
||||
let mut used = Vec::new();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
// Every relationship the markup uses must be writable, or the object would be broken.
|
||||
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
|
||||
if !used.iter().all(writable) {
|
||||
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
|
||||
if !used.iter().all(|id| writable.contains(id.as_str())) {
|
||||
log::warn!("docx: an embedded object's parts are missing; it is left out");
|
||||
return None;
|
||||
}
|
||||
@@ -638,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
|
||||
/// never went through the reader's id limit) costs no scan per attribute.
|
||||
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
out.push(v.clone());
|
||||
out.insert(v.clone());
|
||||
}
|
||||
}
|
||||
for c in e.els() {
|
||||
@@ -852,6 +854,24 @@ fn valid_part_name(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relationship_ids_are_collected_once_each() {
|
||||
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
|
||||
// (each named twice) were a scan of every id collected so far, per attribute.
|
||||
let mut xml = String::from(
|
||||
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
for i in 0..40_000 {
|
||||
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
|
||||
}
|
||||
xml.push_str("</w:p>");
|
||||
let root = xml::parse(xml.as_bytes()).unwrap();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
assert_eq!(used.len(), 40_000);
|
||||
assert!(used.contains("rId39999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charts_cut_to_the_limits_on_save_say_so_once() {
|
||||
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
|
||||
|
||||
@@ -77,6 +77,43 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
|
||||
/// of the same physical row. A drop after the break must still stay on the destination page.
|
||||
#[test]
|
||||
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
|
||||
for gap in [0.0, 6.0, 12.0] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
|
||||
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "page"})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
super::select::with_float(&mut s, |f| {
|
||||
f.dist = gap;
|
||||
f.dist_top = gap;
|
||||
f.dist_bottom = gap;
|
||||
})
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
|
||||
let hit = object_area(&mut s);
|
||||
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
|
||||
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||||
let saved = object_area(&mut copy);
|
||||
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
|
||||
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
|
||||
@@ -323,11 +323,13 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
|
||||
fn push_capped(s: &mut String, t: &str) {
|
||||
let have = s.chars().count();
|
||||
if have < MAX_CELL_CHARS {
|
||||
s.extend(t.chars().take(MAX_CELL_CHARS - have));
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
|
||||
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
|
||||
/// cost its length per further text event.
|
||||
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
|
||||
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
|
||||
s.push(c);
|
||||
*have += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +467,22 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cell_takes_further_text_without_recounting() {
|
||||
let (mut s, mut n) = (String::new(), 0usize);
|
||||
push_capped(&mut s, &mut n, "é");
|
||||
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
|
||||
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
|
||||
// A million more text events on the full cell each cost nothing (they used to count it).
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..1_000_000 {
|
||||
push_capped(&mut s, &mut n, "y");
|
||||
}
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
|
||||
assert!(!s.contains('y'));
|
||||
}
|
||||
|
||||
/// #1287: one long shared string in thousands of cells stays under the text budget.
|
||||
#[test]
|
||||
fn xlsx_shared_strings_cannot_blow_up_memory() {
|
||||
|
||||
@@ -11,6 +11,8 @@ struct OCell {
|
||||
kind: String,
|
||||
value: Option<String>,
|
||||
text: String,
|
||||
/// Characters in `text` (see [`push_capped`]).
|
||||
text_chars: usize,
|
||||
paras: usize,
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
{
|
||||
if c.paras > 0 {
|
||||
push_capped(&mut c.text, "\n");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
}
|
||||
c.paras += 1;
|
||||
}
|
||||
@@ -86,17 +88,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
b"s" if in_p > 0 && skip == 0 => {
|
||||
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, &" ".repeat(k));
|
||||
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
|
||||
}
|
||||
}
|
||||
b"tab" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, "\t");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\t");
|
||||
}
|
||||
}
|
||||
b"line-break" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, "\n");
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -107,7 +109,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
&& let Some(c) = cell.as_mut()
|
||||
{
|
||||
push_capped(&mut c.text, &t);
|
||||
push_capped(&mut c.text, &mut c.text_chars, &t);
|
||||
}
|
||||
}
|
||||
Ev::End(n) => {
|
||||
@@ -181,7 +183,7 @@ fn ods_value(c: &OCell) -> String {
|
||||
("string", Some(v)) if c.text.trim().is_empty() => {
|
||||
// Attribute text obeys the same per-cell cap as `<text:p>` text.
|
||||
let mut s = String::new();
|
||||
push_capped(&mut s, v);
|
||||
push_capped(&mut s, &mut 0, v);
|
||||
s
|
||||
}
|
||||
_ => c.text.clone(),
|
||||
|
||||
@@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
|
||||
/// (`rPh`) left out.
|
||||
fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<String> = None;
|
||||
let mut cur: Option<(String, usize)> = None;
|
||||
let (mut in_t, mut phonetic) = (false, 0usize);
|
||||
walk(xml, |ev| match ev {
|
||||
Ev::Start(e) => match e.local_name().as_ref() {
|
||||
b"si" => cur = Some(String::new()),
|
||||
b"si" => cur = Some((String::new(), 0)),
|
||||
b"t" => in_t = true,
|
||||
b"rPh" => phonetic += 1,
|
||||
_ => {}
|
||||
},
|
||||
Ev::End(n) => match n.as_slice() {
|
||||
b"si" => {
|
||||
if let Some(s) = cur.take()
|
||||
if let Some((s, _)) = cur.take()
|
||||
&& out.len() < MAX_SHARED
|
||||
{
|
||||
out.push(s);
|
||||
@@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
Ev::Text(t) => {
|
||||
if in_t
|
||||
&& phonetic == 0
|
||||
&& let Some(s) = cur.as_mut()
|
||||
&& let Some((s, n)) = cur.as_mut()
|
||||
{
|
||||
push_capped(s, &t);
|
||||
push_capped(s, n, &t);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -182,6 +182,9 @@ struct XCell {
|
||||
style: usize,
|
||||
value: String,
|
||||
inline: String,
|
||||
/// Characters in `value` and `inline` (see [`push_capped`]).
|
||||
value_chars: usize,
|
||||
inline_chars: usize,
|
||||
}
|
||||
|
||||
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
|
||||
@@ -227,9 +230,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
|
||||
Ev::Text(t) => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
if in_v {
|
||||
push_capped(&mut c.value, &t);
|
||||
push_capped(&mut c.value, &mut c.value_chars, &t);
|
||||
} else if in_is_t && phonetic == 0 {
|
||||
push_capped(&mut c.inline, &t);
|
||||
push_capped(&mut c.inline, &mut c.inline_chars, &t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ pub struct Probe<'a> {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
peak: u64,
|
||||
/// Whether a PNG has transparency, read from its chunk headers; `None` for other formats.
|
||||
png_alpha: Option<bool>,
|
||||
}
|
||||
|
||||
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
|
||||
@@ -72,9 +74,48 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
|
||||
}
|
||||
_ => return Err(Refusal::NotAPicture),
|
||||
};
|
||||
if format == ImageFormat::Png {
|
||||
let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?;
|
||||
let (width, height, peak) = sized(width, height, native, 0)?;
|
||||
return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) });
|
||||
}
|
||||
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
|
||||
let (width, height, peak) = measure(&decoder, held)?;
|
||||
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
|
||||
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None })
|
||||
}
|
||||
|
||||
/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8,
|
||||
/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and
|
||||
/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile
|
||||
/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured.
|
||||
fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> {
|
||||
let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes);
|
||||
// The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type.
|
||||
if bytes.get(12..16) != Some(b"IHDR".as_slice()) {
|
||||
return None;
|
||||
}
|
||||
let (width, height) = (be(16)?, be(20)?);
|
||||
let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?);
|
||||
let mut trns = false;
|
||||
let mut at = 8usize;
|
||||
loop {
|
||||
let len = usize::try_from(be(at)?).ok()?;
|
||||
match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? {
|
||||
b"IDAT" => break,
|
||||
b"IEND" => return None,
|
||||
b"tRNS" => trns = true,
|
||||
_ => {}
|
||||
}
|
||||
at = at.checked_add(12)?.checked_add(len)?;
|
||||
}
|
||||
let (channels, alpha) = match color {
|
||||
0 => (1 + u64::from(trns), trns),
|
||||
2 | 3 => (3 + u64::from(trns), trns),
|
||||
4 => (2, true),
|
||||
6 => (4, true),
|
||||
_ => return None,
|
||||
};
|
||||
Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha))
|
||||
}
|
||||
|
||||
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
|
||||
@@ -86,16 +127,20 @@ pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
|
||||
measure(&decoder, 0)?
|
||||
};
|
||||
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
|
||||
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None })
|
||||
}
|
||||
|
||||
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
|
||||
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
|
||||
let (width, height) = decoder.dimensions();
|
||||
sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held)
|
||||
}
|
||||
|
||||
/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]).
|
||||
fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(Refusal::Broken("it has no pixels".into()));
|
||||
}
|
||||
let native = u64::from(decoder.color_type().bytes_per_pixel());
|
||||
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
|
||||
Ok((width, height, peak))
|
||||
}
|
||||
@@ -106,6 +151,18 @@ impl Probe<'_> {
|
||||
self.peak
|
||||
}
|
||||
|
||||
/// Whether the picture has transparency, from its header: a PNG's from its chunk headers
|
||||
/// (an ICC profile isn't inflated to say), another format's from its decoder.
|
||||
pub fn has_alpha(&self) -> bool {
|
||||
if let Some(alpha) = self.png_alpha {
|
||||
return alpha;
|
||||
}
|
||||
if self.dib {
|
||||
return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha());
|
||||
}
|
||||
image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha())
|
||||
}
|
||||
|
||||
/// Whether decoding fits `budget` bytes.
|
||||
pub fn fits(&self, budget: u64) -> bool {
|
||||
self.peak <= budget
|
||||
@@ -305,6 +362,39 @@ mod tests {
|
||||
!c
|
||||
}
|
||||
|
||||
/// `png` with a chunk of `kind` holding `data` placed after IHDR.
|
||||
fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut chunk = (data.len() as u32).to_be_bytes().to_vec();
|
||||
chunk.extend_from_slice(kind);
|
||||
chunk.extend_from_slice(data);
|
||||
let crc = crc32(&chunk[4..]);
|
||||
chunk.extend_from_slice(&crc.to_be_bytes());
|
||||
let mut out = png.get(..33).unwrap().to_vec();
|
||||
out.extend(chunk);
|
||||
out.extend_from_slice(&png[33..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_is_measured_from_its_chunk_headers() {
|
||||
// An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk
|
||||
// (the decoder refused it while reading the header) still measures, and isn't decoded.
|
||||
let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png);
|
||||
let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF");
|
||||
let p = probe(&icc).unwrap();
|
||||
assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8));
|
||||
assert!(!p.has_alpha());
|
||||
assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it");
|
||||
// Transparency: an alpha channel, or tRNS before the image data.
|
||||
assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha());
|
||||
let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]);
|
||||
let p = probe(&trns).unwrap();
|
||||
assert!(p.has_alpha());
|
||||
assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees");
|
||||
// No image data: broken.
|
||||
assert!(probe(&rgb[..33]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_pictures_decode_and_peaks_count_every_buffer() {
|
||||
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
|
||||
|
||||
@@ -286,16 +286,13 @@ impl Alphas {
|
||||
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
|
||||
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
|
||||
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
|
||||
use image::ImageDecoder;
|
||||
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
|
||||
// Formats that can't be transparent aren't decoded.
|
||||
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
|
||||
return Ok(None);
|
||||
}
|
||||
// Only the header is read here (a PNG's `tRNS` counts as transparency).
|
||||
let has_alpha =
|
||||
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
|
||||
if !has_alpha {
|
||||
if !pic.has_alpha() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pixels = u64::from(pic.width) * u64::from(pic.height);
|
||||
|
||||
@@ -438,13 +438,23 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi:
|
||||
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
|
||||
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
|
||||
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
|
||||
/// them they are on the new page with it (#4262).
|
||||
/// them they are on the new page with it (#4262). The text may be in the same row's
|
||||
/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107).
|
||||
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; p.objects.len()];
|
||||
for (li, l) in pl.lines.iter().enumerate() {
|
||||
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
|
||||
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
|
||||
&& !clusters.iter().any(para::takes_room);
|
||||
let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak));
|
||||
// A wrap can leave only the zero-width anchor in the row's left segment, with its
|
||||
// text on the right. That is still visible content after the break, not a cover box.
|
||||
let lead = after_break
|
||||
&& !clusters.iter().any(para::takes_room)
|
||||
&& !pl
|
||||
.lines
|
||||
.iter()
|
||||
.skip(li + 1)
|
||||
.take_while(|next| next.beside)
|
||||
.any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room));
|
||||
for c in clusters {
|
||||
let para::ClKind::Object(oi) = c.kind else { continue };
|
||||
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
|
||||
|
||||
@@ -842,10 +842,22 @@ fn draw_image(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Widest path drawn, in device pixels a side: 64 times the largest canvas. The rasteriser
|
||||
/// flattens a curve into as many points as its size asks for, before clipping, so a path with
|
||||
/// coordinates of 1e15 (file data in a native document) would take it forever or run out of
|
||||
/// memory; one this far past every canvas shows nothing a real drawing would.
|
||||
const MAX_PATH_PIXELS: f64 = 1_048_576.0;
|
||||
|
||||
/// Whether `path` under `view` is small enough to draw ([`MAX_PATH_PIXELS`]) and finite.
|
||||
fn path_extent_ok(view: Affine, path: &kurbo::BezPath) -> bool {
|
||||
let b = view.transform_rect_bbox(path.bounding_box());
|
||||
path.is_finite() && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_PATH_PIXELS)
|
||||
}
|
||||
|
||||
fn draw_path(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
|
||||
let Draw::Path { segs, fill, stroke, stroke_width, dash, cap } = it else { return };
|
||||
let path = wordcraft_layout::display::seg_path(segs);
|
||||
if path.elements().is_empty() {
|
||||
if path.elements().is_empty() || !path_extent_ok(fr.view, &path) {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(fr.view);
|
||||
|
||||
@@ -1411,3 +1411,18 @@ fn a_line_runs_from_top_left_to_bottom_right() {
|
||||
let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0), None);
|
||||
assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_far_past_every_canvas_are_not_flattened() {
|
||||
let line = |x: f64| {
|
||||
let mut p = kurbo::BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.curve_to((x, 0.0), (x, x), (0.0, x));
|
||||
p
|
||||
};
|
||||
assert!(path_extent_ok(Affine::IDENTITY, &line(500.0)));
|
||||
assert!(path_extent_ok(Affine::scale(4.0), &line(200_000.0)), "a large page at a high zoom");
|
||||
assert!(!path_extent_ok(Affine::IDENTITY, &line(1e15)));
|
||||
assert!(!path_extent_ok(Affine::scale(1e12), &line(10.0)));
|
||||
assert!(!path_extent_ok(Affine::IDENTITY, &line(f64::NAN)));
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user