mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 11:19:54 -06:00
Compare commits
18
Commits
+5
-1
@@ -177,6 +177,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 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.
|
||||
- 2026-11-04: CLI `convert` rejects a missing or repeated `--format` value before opening the input or writing output, matching the existing page/scale option checks. Readiness estimates unchanged.
|
||||
- 2026-11-04: MHT import keeps semicolons and escaped quotes inside quoted MIME parameters, so an unrelated parameter cannot supply a false multipart boundary and hide the document text. Readiness estimates unchanged.
|
||||
- 2026-11-04: Ctrl/Cmd+Shift+V pastes the clipboard as Keep Text Only when no formatting has been picked up (by Ctrl/Cmd+Shift+C or the Format Painter), as in Word for Windows; after either it pastes the formatting, as often as asked until the next Ctrl/Cmd+Shift+C or Format Painter pick replaces it, as in Word, and Ctrl/Cmd+Shift+C no longer arms the Format Painter brush (#1537, #4943; takes over #1562).
|
||||
- 2026-10-30: Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits. `\int` + Space, then `_0^1` and Space, gives ∫ with its limits and the caret in its operand, and `\int_0^1` typed in one go does the same, since any character that can't continue `\name` corrects it; this is observed in Word for Windows on #4938. Enter or an arrow key builds it without going into the operand, and `∫∫` puts the operand in the inner operator; those two are not observed in Word (#4917). Readiness unchanged.
|
||||
|
||||
- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged.
|
||||
@@ -187,7 +188,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
|
||||
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
|
||||
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
|
||||
|
||||
- 2026-11-04: OpenDocument files have no display settings any more (gap 19): File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values are gone. WordCraft makes one choice for each case the standard leaves open, the one the default made, and still follows what a file states ([docs/odf-interpretation.md](docs/odf-interpretation.md)).
|
||||
- 2026-10-31: Dialogs are modal, as in Word (#3859): while one is open, the ribbon, File, the page and the panes of its document window take no clicks, and typing and shortcuts (Ctrl+O…) do nothing until it closes; WordCraft's own Open/Save As window is modal too. A press beside a modal dialog shakes it a little, so it is clear why nothing behind it reacts (Word beeps); with animation effects off, its border flashes instead. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac 16.114.1 (checked on a newer build than the 16.113.4 the parity target names).
|
||||
- 2026-11-04: OpenDocument files are shown in one of two styles (gap 19): Microsoft Word style, the default, or LibreOffice style (File › Options › Advanced › OpenDocument files; `file.options` `{"odf": {"preset": "word"}}` or `"libreoffice"`; `WORDCRAFT_ODF=word` or `WORDCRAFT_ODF=libreoffice`). Custom stays for one's own value per case; `wordcraft`, the earlier id of Word's style, is still taken.
|
||||
@@ -257,6 +257,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -163,7 +163,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
|
||||
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
|
||||
painter_pick(s);
|
||||
// One format buffer, as in Word: Copy Formatting replaces an armed Format Painter.
|
||||
s.painter = None;
|
||||
s.copied_format = Some(picked_format(s));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+Shift+C")
|
||||
@@ -819,11 +821,21 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session) {
|
||||
/// The character and paragraph formatting at the start of the selection.
|
||||
fn picked_format(s: &Session) -> (wordcraft_doc::props::CharProps, wordcraft_doc::props::ParaProps) {
|
||||
let f = s.sel.ordered().0;
|
||||
// A symbol's own font isn't formatting to paint onto text.
|
||||
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol()).unwrap_or_default();
|
||||
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
|
||||
// Formatting only: not the source's hyperlink or revision marks (#5616).
|
||||
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol().formatting()).unwrap_or_default();
|
||||
let para = s.doc.para_at(&f).map(|p| p.props.clone().formatting()).unwrap_or_default();
|
||||
(chr, para)
|
||||
}
|
||||
|
||||
fn painter_pick(s: &mut Session) {
|
||||
let (chr, para) = picked_format(s);
|
||||
// One format buffer, as in Word: the pick is also what the next Paste Formatting applies,
|
||||
// and stays after the brush is used up or cancelled.
|
||||
s.copied_format = Some((chr.clone(), para.clone()));
|
||||
s.painter = Some((chr, para, false));
|
||||
}
|
||||
|
||||
@@ -839,24 +851,25 @@ fn painter(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"active": true}))
|
||||
}
|
||||
|
||||
/// Apply the format painter's formatting to the selection.
|
||||
/// Apply the format painter's formatting to the selection, or else what Copy Formatting picked
|
||||
/// up (which stays for the next Paste Formatting).
|
||||
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
|
||||
let (chr, para, sticky) = match (s.painter.clone(), s.copied_format.clone()) {
|
||||
(Some(brush), _) => brush,
|
||||
(None, Some((chr, para))) => (chr, para, true),
|
||||
(None, None) => return Err(CmdError::Failed("no formatting copied".into())),
|
||||
};
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
// No selection: apply paragraph formatting (and word formatting like Word).
|
||||
let f = s.sel.focus.clone();
|
||||
if let Some(pp) = s.doc.para_at(&f) {
|
||||
let (x, y) = pp.word_at(f.off);
|
||||
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
|
||||
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = c.cleared().overlaid(&chr))?;
|
||||
}
|
||||
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
|
||||
} else {
|
||||
s.doc.format_range(&a, &b, &|c| {
|
||||
let link = c.link.clone();
|
||||
*c = c.cleared().overlaid(&chr);
|
||||
c.link = link;
|
||||
})?;
|
||||
s.doc.format_range(&a, &b, &|c| *c = c.cleared().overlaid(&chr))?;
|
||||
if a.path != b.path || a.off == 0 {
|
||||
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,10 @@ pub struct Session {
|
||||
pub initials: String,
|
||||
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
|
||||
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
|
||||
/// Formatting picked up by Copy Formatting (Mod+Shift+C): Paste Formatting applies it as often
|
||||
/// as asked, as in Word, until the next Copy Formatting. A Format Painter pick is stored here
|
||||
/// as well.
|
||||
pub copied_format: Option<(CharProps, wordcraft_doc::props::ParaProps)>,
|
||||
/// Last message for the status bar / agents.
|
||||
pub status: String,
|
||||
/// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none
|
||||
|
||||
@@ -250,6 +250,7 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
|
||||
@@ -107,6 +107,7 @@ impl Session {
|
||||
author: "WordCraft User".into(),
|
||||
initials: String::new(),
|
||||
painter: None,
|
||||
copied_format: None,
|
||||
status: String::new(),
|
||||
new_header_parts,
|
||||
hf_return,
|
||||
|
||||
@@ -90,6 +90,7 @@ impl Session {
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
copied_format: _,
|
||||
status: _,
|
||||
// Headers just created to edit: closing checks they still exist and are empty.
|
||||
new_header_parts: _,
|
||||
|
||||
@@ -12859,6 +12859,161 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// #5616: Copy Formatting's formatting stays for every Paste Formatting, as in Word; it doesn't
|
||||
/// arm the one-shot Format Painter brush.
|
||||
#[test]
|
||||
fn copied_formatting_pastes_again_and_again() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
assert!(s.painter.is_none());
|
||||
for word in ["one", "two"] {
|
||||
run(&mut s, "select.text", json!({"text": word}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true), "{word}");
|
||||
}
|
||||
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body), "Bold one two");
|
||||
}
|
||||
|
||||
/// #5616: Copy Formatting and the Format Painter share one format buffer: each replaces the other,
|
||||
/// so a used-up brush never hands over to older copied formatting.
|
||||
#[test]
|
||||
fn copy_formatting_and_the_format_painter_replace_each_other() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
assert!(s.painter.is_some());
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
assert!(s.painter.is_none(), "Copy Formatting clears an armed brush");
|
||||
assert!(s.copied_format.is_some());
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
assert!(s.painter.is_some());
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert!(s.painter.is_none(), "a used-up brush is gone");
|
||||
assert!(s.copied_format.is_some(), "its pick stays for Paste Formatting");
|
||||
}
|
||||
|
||||
/// #5616: arming and cancelling the Format Painter leaves Copy Formatting's pick; a brush's pick
|
||||
/// is what Paste Formatting applies next.
|
||||
#[test]
|
||||
fn cancelling_the_format_painter_keeps_the_copied_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
assert!(s.painter.is_none());
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true));
|
||||
// A brush picked from plain text replaces the bold pick.
|
||||
run(&mut s, "select.text", json!({"text": "two"}));
|
||||
run(&mut s, "edit.formatPainter", json!({}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert_ne!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true));
|
||||
}
|
||||
|
||||
/// #5616: copied formatting carries no tracked paragraph formatting or numbering change.
|
||||
#[test]
|
||||
fn copied_paragraph_formatting_carries_no_tracked_changes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "one\ntwo"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": false}));
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
let f = s.sel.focus.clone();
|
||||
assert!(s.doc.para_at(&f).unwrap().props.fmt_change.is_some(), "the source paragraph has a tracked formatting change");
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
let (_, para) = s.copied_format.clone().unwrap();
|
||||
assert!(para.fmt_change.is_none() && para.num_change.is_none());
|
||||
run(&mut s, "select.text", json!({"text": "two"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
let f = s.sel.focus.clone();
|
||||
let p = s.doc.para_at(&f).unwrap().props.clone();
|
||||
assert!(p.fmt_change.is_none() && p.num_change.is_none());
|
||||
}
|
||||
|
||||
/// #5616: copied formatting carries no hyperlink or revision marks, even into a collapsed caret.
|
||||
#[test]
|
||||
fn copied_formatting_carries_no_hyperlink() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "source target"}));
|
||||
run(&mut s, "select.text", json!({"text": "source"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
|
||||
run(&mut s, "select.text", json!({"text": "so"}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
let (chr, _) = s.copied_format.clone().unwrap();
|
||||
assert_eq!(chr.bold, Some(true));
|
||||
assert!(chr.link.is_none() && chr.ins.is_none() && chr.del.is_none() && chr.fmt_change.is_none());
|
||||
run(&mut s, "select.text", json!({"text": "target"}));
|
||||
run(&mut s, "select.collapse", json!({}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
let f = s.sel.focus.clone();
|
||||
let link = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).link.clone());
|
||||
assert_eq!(link, Some(None), "the target didn't become a link");
|
||||
}
|
||||
|
||||
/// #5616: Paste Formatting at a bare caret keeps the word's own hyperlink and tracked insertion.
|
||||
#[test]
|
||||
fn a_caret_paste_formatting_keeps_the_targets_link_and_tracked_marks() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "source target"}));
|
||||
run(&mut s, "select.text", json!({"text": "source"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
run(&mut s, "select.text", json!({"text": "target"}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
|
||||
run(&mut s, "select.collapse", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
let at_caret = |s: &Session| {
|
||||
let f = s.sel.focus.clone();
|
||||
s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default()
|
||||
};
|
||||
let c = at_caret(&s);
|
||||
assert!(c.link.is_some(), "the target kept its hyperlink");
|
||||
assert_eq!(c.bold, Some(true));
|
||||
// A tracked insertion stays tracked.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.insert", json!({"text": " added"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": false}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
assert!(at_caret(&s).ins.is_some(), "the insertion is tracked before");
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
assert!(at_caret(&s).ins.is_some(), "Paste Formatting kept the insertion mark");
|
||||
}
|
||||
|
||||
/// #5616: Paste Formatting over a selection keeps the target's hyperlink too.
|
||||
#[test]
|
||||
fn a_range_paste_formatting_keeps_the_targets_link() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "source target"}));
|
||||
run(&mut s, "select.text", json!({"text": "source"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copyFormat", json!({}));
|
||||
run(&mut s, "select.text", json!({"text": "target"}));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
|
||||
run(&mut s, "select.text", json!({"text": "target"}));
|
||||
run(&mut s, "edit.pasteFormat", json!({}));
|
||||
let (a, _) = s.sel.ordered();
|
||||
let c = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default();
|
||||
assert!(c.link.is_some(), "the target kept its hyperlink");
|
||||
assert_eq!(c.bold, Some(true));
|
||||
}
|
||||
|
||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -242,6 +242,18 @@ pub struct CanvasState {
|
||||
pub(crate) v_pressed: bool,
|
||||
/// The modifiers last held with Mod, for a Mod+V whose Mod is released before V.
|
||||
pub(crate) paste_mods: Option<egui::Modifiers>,
|
||||
/// The modifiers a paste event is read with: those of Mod+V's press or a Mod change, until an
|
||||
/// unrelated key, a pointer press or a focus change. (`paste_mods` also serves the release path.)
|
||||
pub(crate) paste_event_mods: Option<egui::Modifiers>,
|
||||
/// On the web, Mod+Shift+V pasted formatting from its key press: the browser's paste event
|
||||
/// that follows is the same keystroke and pastes nothing (#5616). Any other key press clears it.
|
||||
pub(crate) ignore_next_paste: bool,
|
||||
/// When `ignore_next_paste` was set (input time, seconds): the browser's paired paste comes
|
||||
/// with the key, so the wait ends after `PASTE_WAIT` and a later paste isn't dropped.
|
||||
pub(crate) paste_wait_since: Option<f64>,
|
||||
/// The platform sends a paste event after Mod+Shift+V's key (a browser does; a desktop shell
|
||||
/// doesn't). Only then does the key leave a paste event to ignore or to let paste text.
|
||||
pub(crate) paste_event_follows_chord: bool,
|
||||
/// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret.
|
||||
pub table_tool: Option<crate::table_pen::TableTool>,
|
||||
/// The Draw Table stroke (or eraser press) in progress.
|
||||
@@ -423,6 +435,10 @@ impl Default for CanvasState {
|
||||
pasted: false,
|
||||
v_pressed: false,
|
||||
paste_mods: None,
|
||||
paste_event_mods: None,
|
||||
ignore_next_paste: false,
|
||||
paste_wait_since: None,
|
||||
paste_event_follows_chord: cfg!(target_arch = "wasm32"),
|
||||
swallow_text: false,
|
||||
ime_join_text: None,
|
||||
ime_reset: false,
|
||||
|
||||
@@ -46,6 +46,8 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
interrupted_preedit: std::mem::take(&mut app.canvas.ime_interrupted_preedit),
|
||||
interrupted_replacement: EquationReplacementDrop::None,
|
||||
};
|
||||
let now = ctx.input(|i| i.time);
|
||||
expire_paste_wait(app, now);
|
||||
let held = ctx.input(|i| i.modifiers);
|
||||
if held.command {
|
||||
app.canvas.paste_mods = Some(held);
|
||||
@@ -60,7 +62,8 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
continue;
|
||||
}
|
||||
note_key_state(app, &e);
|
||||
if drop_refused_replacement(app, &e, &mut batch)
|
||||
if ignored_paste(app, &e)
|
||||
|| drop_refused_replacement(app, &e, &mut batch)
|
||||
|| prefilter(app, ctx, &e, alt_codes)
|
||||
|| paste_release(app, &e)
|
||||
|| equation_event(app, ctx, &e, &mut batch)
|
||||
@@ -69,6 +72,27 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
}
|
||||
handle_event(app, ctx, e);
|
||||
}
|
||||
if app.canvas.ignore_next_paste && app.canvas.paste_wait_since.is_none() {
|
||||
app.canvas.paste_wait_since = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the paste event paired with Mod+Shift+V is waited for. A browser sends it with the
|
||||
/// key, in the same frame; with no text on the clipboard it sends none at all.
|
||||
const PASTE_WAIT: f64 = 1.0;
|
||||
|
||||
/// Ends a wait for the paired paste event that never came, so a later paste (a browser menu's,
|
||||
/// say) isn't dropped (#5616).
|
||||
fn expire_paste_wait(app: &mut WordApp, now: f64) {
|
||||
if !app.canvas.ignore_next_paste {
|
||||
app.canvas.paste_wait_since = None;
|
||||
} else if app.canvas.paste_wait_since.is_some_and(|t| now - t > PASTE_WAIT) {
|
||||
app.canvas.ignore_next_paste = false;
|
||||
app.canvas.paste_wait_since = None;
|
||||
// No key came since the chord (any key ends the wait), so noted modifiers are the
|
||||
// chord's: a later paste reads the modifiers held then.
|
||||
app.canvas.paste_event_mods = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// State carried across the events of one raw batch.
|
||||
@@ -158,20 +182,52 @@ fn stale_composition(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool
|
||||
fn note_key_state(app: &mut WordApp, e: &egui::Event) {
|
||||
// Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod
|
||||
// can go down and up within one frame, leaving the frame's modifiers without it.
|
||||
if matches!(e, egui::Event::Key { pressed: true, key, .. } if !is_modifier_key(*key))
|
||||
|| matches!(e, egui::Event::WindowFocused(_) | egui::Event::PointerButton { pressed: true, .. })
|
||||
{
|
||||
app.canvas.ignore_next_paste = false;
|
||||
}
|
||||
match &e {
|
||||
egui::Event::ModifiersChanged(m) if m.command => app.canvas.paste_mods = Some(*m),
|
||||
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, .. } => app.canvas.v_pressed = true,
|
||||
egui::Event::ModifiersChanged(m) => {
|
||||
app.canvas.paste_event_mods = m.command.then_some(*m);
|
||||
if m.command {
|
||||
app.canvas.paste_mods = Some(*m);
|
||||
}
|
||||
}
|
||||
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, modifiers, .. } => {
|
||||
app.canvas.v_pressed = true;
|
||||
app.canvas.paste_event_mods = modifiers.command.then_some(*modifiers);
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => {
|
||||
app.canvas.paste_mods = modifiers.command.then_some(*modifiers);
|
||||
// An unrelated key's modifiers mustn't steer a later keyless paste.
|
||||
app.canvas.paste_event_mods = None;
|
||||
}
|
||||
egui::Event::PointerButton { pressed: true, .. } => app.canvas.paste_event_mods = None,
|
||||
egui::Event::WindowFocused(_) => {
|
||||
app.canvas.v_pressed = false;
|
||||
app.canvas.paste_mods = None;
|
||||
app.canvas.paste_event_mods = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's paste event that follows a Mod+Shift+V that pasted formatting is the same
|
||||
/// keystroke: ignored before an equation or a picture-file paste can take it (#5616).
|
||||
/// With no text on the clipboard eframe sends no paste event, so the wait ends at the next key,
|
||||
/// pointer press or focus change, or after `PASTE_WAIT`.
|
||||
fn ignored_paste(app: &mut WordApp, e: &egui::Event) -> bool {
|
||||
if matches!(e, egui::Event::Paste(_)) && std::mem::take(&mut app.canvas.ignore_next_paste) {
|
||||
// Not a Mod+V with no text either: the release mustn't paste a picture.
|
||||
app.canvas.pasted = true;
|
||||
// Its Mod+Shift is used up too, or a later menu-bar paste would read as Keep Text Only.
|
||||
app.canvas.paste_event_mods = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The replacement paired with a refused equation deletion is dropped.
|
||||
fn drop_refused_replacement(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool {
|
||||
// The replacement is the event immediately after the refused deletion: one `Commit`,
|
||||
@@ -509,10 +565,15 @@ fn handle_event(app: &mut WordApp, ctx: &egui::Context, e: egui::Event) {
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(text)) => ime_commit(app, text),
|
||||
egui::Event::Paste(t) => {
|
||||
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
// The modifiers held with Mod in event order: Shift may be up by the frame's end.
|
||||
app.canvas.paste_mods = None;
|
||||
let m = app.canvas.paste_event_mods.take().unwrap_or_else(|| ctx.input(|i| i.modifiers));
|
||||
// A browser sends Mod+Shift+V as a paste too: Keep Text Only, as the key does on the
|
||||
// desktop (#1537). (egui-winit sends that chord as a key, and Shift+Insert has no Mod.)
|
||||
let id = match (m.alt, m.shift) {
|
||||
(true, true) => "edit.pasteText",
|
||||
(true, false) => "edit.pasteSpecial",
|
||||
(false, true) if m.command => "edit.pasteText",
|
||||
_ => "edit.paste",
|
||||
};
|
||||
let _ = app.run(id, json!({"text": t}));
|
||||
|
||||
@@ -132,7 +132,26 @@ pub(super) fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
|
||||
if m.alt && !crate::widgets::enabled(app, spec.id) {
|
||||
return false;
|
||||
}
|
||||
let _ = app.run(spec.id, json!({}));
|
||||
// Mod+Shift+V pastes the formatting Mod+Shift+C (or the Format Painter) picked up; with
|
||||
// none picked up it pastes the clipboard as text only, as Word for Windows does (#1537,
|
||||
// #4943). Another key bound to Paste Formatting only pastes formatting.
|
||||
let id = if spec.id == "edit.pasteFormat" {
|
||||
let formatting = app.session.painter.is_some() || app.session.copied_format.is_some();
|
||||
let chord = key == Key::V && m.command && m.shift && !m.alt;
|
||||
// A browser sends a paste event after that chord (the desktop doesn't): after
|
||||
// pasting formatting it's ignored; with none, it pastes the text there, once. A
|
||||
// rebinding to another key makes no paste event.
|
||||
let paste_follows = chord && app.canvas.paste_event_follows_chord;
|
||||
app.canvas.ignore_next_paste = formatting && paste_follows;
|
||||
app.canvas.paste_wait_since = None;
|
||||
if !formatting && paste_follows {
|
||||
return true;
|
||||
}
|
||||
if !formatting && chord { "edit.pasteText" } else { spec.id }
|
||||
} else {
|
||||
spec.id
|
||||
};
|
||||
let _ = app.run(id, json!({}));
|
||||
return true;
|
||||
}
|
||||
let Some(name) = name else { return false };
|
||||
|
||||
@@ -133,6 +133,44 @@ fn only_plain_paste_releases_paste_a_clipboard_picture() {
|
||||
assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false)));
|
||||
}
|
||||
|
||||
/// #1537: a browser sends Mod+Shift+V as a paste event; it pastes text only, in the destination's
|
||||
/// formatting, though the clipboard also holds bold HTML. Plain Mod+V keeps the HTML's bold.
|
||||
#[test]
|
||||
fn a_mod_shift_paste_event_keeps_text_only() {
|
||||
for (modifiers, bold) in [(Modifiers::COMMAND | Modifiers::SHIFT, false), (Modifiers::COMMAND, true)] {
|
||||
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
let ctx = egui::Context::default();
|
||||
mods_frame(&ctx, &mut a, modifiers, vec![egui::Event::Paste("external".into())]);
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "external", "{modifiers:?}");
|
||||
let state = a.session.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!(state.get("bold").and_then(serde_json::Value::as_bool) == Some(true), bold, "{modifiers:?}: {state}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5616: a browser sends Mod+Shift+V as its key and then a paste event. After Mod+Shift+C the
|
||||
/// key pastes the formatting and the paste event that follows doesn't replace the text; a later
|
||||
/// Mod+V still pastes.
|
||||
#[test]
|
||||
fn a_paste_event_after_mod_shift_v_pasted_formatting_is_ignored() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
a.run("text.insert", json!({"text": "source target"})).unwrap();
|
||||
a.run("select.text", json!({"text": "source"})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("edit.copyFormat", json!({})).unwrap();
|
||||
a.run("select.text", json!({"text": "target"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord), egui::Event::Paste("clipboard text".into())]);
|
||||
let body = |a: &WordApp| a.session.doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert_eq!(body(&a), "source target", "the paste event didn't replace the formatted text");
|
||||
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(bold, Some(true), "the key pasted the formatting");
|
||||
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![key_event(Key::V, true, Modifiers::COMMAND), egui::Event::Paste("pasted".into())]);
|
||||
assert_eq!(body(&a), "source pasted", "the next Mod+V pastes");
|
||||
}
|
||||
|
||||
/// An empty document whose clipboard holds a 4 × 4 picture.
|
||||
fn picture_app() -> WordApp {
|
||||
use crate::paste_picture::ClipboardPicture;
|
||||
@@ -154,6 +192,129 @@ fn mods_frame(ctx: &egui::Context, a: &mut WordApp, modifiers: Modifiers, events
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas();
|
||||
}
|
||||
|
||||
/// A source word in bold with Copy Formatting picked up, and "target" selected.
|
||||
fn copied_bold_app() -> WordApp {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "source target"})).unwrap();
|
||||
a.run("select.text", json!({"text": "source"})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("edit.copyFormat", json!({})).unwrap();
|
||||
a.run("select.text", json!({"text": "target"})).unwrap();
|
||||
a
|
||||
}
|
||||
|
||||
/// #5616: on a desktop no paste event follows Mod+Shift+V's key, so none is swallowed: a later
|
||||
/// paste (from a menu, say) pastes.
|
||||
#[test]
|
||||
fn a_desktop_paste_after_mod_shift_v_is_not_swallowed() {
|
||||
let mut a = copied_bold_app();
|
||||
assert!(!a.canvas.paste_event_follows_chord || cfg!(target_arch = "wasm32"));
|
||||
a.canvas.paste_event_follows_chord = false;
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord)]);
|
||||
assert!(!a.canvas.ignore_next_paste);
|
||||
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![egui::Event::Paste("later".into())]);
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "source later");
|
||||
}
|
||||
|
||||
/// #5616: a pointer press ends the wait for the browser's paste event, so a later keyless paste
|
||||
/// (context menu, touch) isn't swallowed.
|
||||
#[test]
|
||||
fn a_pointer_press_ends_the_wait_for_the_ignored_paste() {
|
||||
let mut a = copied_bold_app();
|
||||
a.canvas.ignore_next_paste = true;
|
||||
let ctx = egui::Context::default();
|
||||
let press =
|
||||
egui::Event::PointerButton { pos: egui::pos2(5.0, 5.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE };
|
||||
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![press]);
|
||||
assert!(!a.canvas.ignore_next_paste);
|
||||
}
|
||||
|
||||
/// #5616: the modifiers noted for a paste are used up by it: a later paste with no Mod held
|
||||
/// keeps the HTML's formatting instead of reading the stale Mod+Shift.
|
||||
#[test]
|
||||
fn a_paste_event_uses_up_the_noted_paste_modifiers() {
|
||||
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
let ctx = egui::Context::default();
|
||||
mods_frame(&ctx, &mut a, Modifiers::COMMAND | Modifiers::SHIFT, vec![egui::Event::Paste("external".into())]);
|
||||
assert!(a.canvas.paste_mods.is_none());
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![egui::Event::Paste("external".into())]);
|
||||
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(bold, Some(true), "the later paste was a plain paste");
|
||||
}
|
||||
|
||||
/// #5616: the dropped browser paste uses up its Mod+Shift, so a later menu-bar paste with no key
|
||||
/// or pointer press in between isn't read as Keep Text Only.
|
||||
#[test]
|
||||
fn the_ignored_paste_uses_up_its_paste_modifiers() {
|
||||
let mut a = copied_bold_app();
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord), egui::Event::Paste("x".into())]);
|
||||
assert!(!a.canvas.ignore_next_paste);
|
||||
assert!(a.canvas.paste_event_mods.is_none());
|
||||
}
|
||||
|
||||
/// #5616: with no text on the clipboard the browser sends no paste event after Mod+Shift+V; the
|
||||
/// wait for it ends, so a later menu-bar paste with no key or pointer press in between pastes.
|
||||
#[test]
|
||||
fn a_menu_bar_paste_after_an_unpaired_mod_shift_v_pastes() {
|
||||
let mut a = copied_bold_app();
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
let frame = |a: &mut WordApp, time: f64, events: Vec<egui::Event>| {
|
||||
ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas();
|
||||
};
|
||||
frame(&mut a, 10.0, vec![egui::Event::ModifiersChanged(chord), key_event(Key::V, true, chord)]);
|
||||
assert!(a.canvas.ignore_next_paste, "the key waits for its paired paste");
|
||||
// No Mod release reaches the canvas (the menu bar takes it).
|
||||
frame(&mut a, 10.5, vec![]);
|
||||
assert!(a.canvas.ignore_next_paste, "still within the wait");
|
||||
frame(&mut a, 12.0, vec![]);
|
||||
assert!(!a.canvas.ignore_next_paste, "the wait is over");
|
||||
assert!(a.canvas.paste_event_mods.is_none(), "the chord's Mod+Shift is used up");
|
||||
frame(&mut a, 12.1, vec![egui::Event::Paste("menu".into())]);
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "source menu", "the menu-bar paste pasted");
|
||||
}
|
||||
|
||||
/// #5616: the browser paste event after a formatting paste is dropped before an open equation
|
||||
/// can type it.
|
||||
#[test]
|
||||
fn the_ignored_paste_event_never_reaches_an_open_equation() {
|
||||
let mut a = copied_bold_app();
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
a.run("insert.equation", json!({"linear": "abc"})).unwrap();
|
||||
a.canvas.ignore_next_paste = true;
|
||||
let ctx = egui::Context::default();
|
||||
let before = a.session.math.clone();
|
||||
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![egui::Event::Paste("zzz".into())]);
|
||||
assert_eq!(a.session.math, before, "the ignored paste typed nothing into the equation");
|
||||
assert!(!a.canvas.ignore_next_paste);
|
||||
}
|
||||
|
||||
/// #5616: only Mod+Shift+V leaves a browser paste event; Paste Formatting on another key neither
|
||||
/// arms the ignore flag nor waits for a paste.
|
||||
#[test]
|
||||
fn paste_formatting_on_another_key_leaves_no_paste_to_ignore() {
|
||||
for formatting in [true, false] {
|
||||
let mut a = if formatting {
|
||||
copied_bold_app()
|
||||
} else {
|
||||
WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default())
|
||||
};
|
||||
a.canvas.paste_event_follows_chord = true;
|
||||
a.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&json!({"assigned": {"Mod+Alt+P": "edit.pasteFormat"}}));
|
||||
let m = Modifiers::COMMAND | Modifiers::ALT;
|
||||
super::shortcuts::dispatch(&mut a, Key::P, m);
|
||||
assert!(!a.canvas.ignore_next_paste, "formatting={formatting}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A press or release of `key` with `modifiers`.
|
||||
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
|
||||
@@ -1335,3 +1496,21 @@ fn typing_with_smart_art_selected_types_into_an_item() {
|
||||
let g = wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).expect("still there");
|
||||
assert_eq!(g.items[0].text, "Ada", "one undo step for the typing");
|
||||
}
|
||||
|
||||
/// #5616: modifiers recorded for an unrelated key (Mod+Shift+C) don't steer a later keyless paste,
|
||||
/// whether Mod was released or is still held with no change event.
|
||||
#[test]
|
||||
fn an_unrelated_keys_modifiers_do_not_steer_a_later_paste() {
|
||||
for mod_still_held in [false, true] {
|
||||
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
let ctx = egui::Context::default();
|
||||
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::C, true, chord)]);
|
||||
// Shift released, Mod still held: Mod+C's chord isn't the paste's modifiers.
|
||||
let after = if mod_still_held { Modifiers::COMMAND } else { Modifiers::NONE };
|
||||
mods_frame(&ctx, &mut a, after, vec![egui::Event::Paste("external".into())]);
|
||||
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
|
||||
assert_eq!(bold, Some(true), "held={mod_still_held}: a plain paste keeps the HTML's bold");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4276,7 +4276,8 @@ fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() {
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
h.event(key(egui::Key::C, modifiers));
|
||||
h.step();
|
||||
assert!(h.state().session.painter.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting");
|
||||
assert!(h.state().session.copied_format.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting");
|
||||
assert!(h.state().session.painter.is_none(), "{modifiers:?}: Copy Formatting doesn't arm the Format Painter brush");
|
||||
assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Copy Formatting preserved clipboard history");
|
||||
assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: the existing clipboard text was preserved");
|
||||
assert!(copied(&h).is_empty(), "{modifiers:?}: no text reached the system clipboard: {:?}", copied(&h));
|
||||
@@ -4287,14 +4288,49 @@ fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() {
|
||||
h.event(key(egui::Key::V, modifiers));
|
||||
h.step();
|
||||
assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: Paste Formatting made the target bold");
|
||||
assert!(h.state().session.painter.is_none(), "{modifiers:?}: the non-sticky painter is used up");
|
||||
assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: pasting formatting left the document text alone");
|
||||
assert!(h.state().session.copied_format.is_some(), "{modifiers:?}: copied formatting stays for the next Paste Formatting");
|
||||
// Pasted again, as in Word: formatting again, never the clipboard's text (#5616).
|
||||
h.state_mut().run("caret.docEnd", json!({})).unwrap();
|
||||
h.state_mut().run("text.insert", json!({"text": " more"})).unwrap();
|
||||
h.state_mut().run("select.text", json!({"text": "more"})).unwrap();
|
||||
// It types on in the pasted bold: make it plain first.
|
||||
h.state_mut().run("format.bold", json!({})).unwrap();
|
||||
assert_eq!(bold(&mut h), Some(false));
|
||||
h.event(key(egui::Key::V, modifiers));
|
||||
h.step();
|
||||
assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: a second Paste Formatting made the next target bold");
|
||||
assert_eq!(body_text(h.state()), "Bold plain more", "{modifiers:?}: the second paste left the text alone");
|
||||
assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Paste Formatting preserved clipboard history");
|
||||
assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: Paste Formatting preserved the clipboard text");
|
||||
assert!(copied(&h).is_empty(), "{modifiers:?}: pasting formatting put no text on the system clipboard: {:?}", copied(&h));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1537/#4943: with no formatting picked up, Ctrl/Cmd+Shift+V pastes the clipboard as text
|
||||
/// only (Word for Windows): the text arrives in the destination's formatting, not the source's.
|
||||
#[test]
|
||||
fn ctrl_shift_v_without_copied_formatting_pastes_text_only() {
|
||||
let mut a = app();
|
||||
a.run("text.insert", json!({"text": "Bold plain"})).unwrap();
|
||||
a.run("select.text", json!({"text": "Bold"})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("edit.copy", json!({})).unwrap();
|
||||
a.run("select.text", json!({"text": "plain"})).unwrap();
|
||||
// The destination has formatting of its own, so a paste that dropped it would show.
|
||||
a.run("format.italic", json!({})).unwrap();
|
||||
assert!(a.session.painter.is_none());
|
||||
let mut h = harness(a);
|
||||
h.run_steps(6);
|
||||
let modifiers = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT;
|
||||
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: true, repeat: false, modifiers });
|
||||
h.step();
|
||||
assert_eq!(body_text(h.state()), "Bold Bold", "the clipboard text replaced the selection");
|
||||
let bold = h.state_mut().run("format.state", json!({})).unwrap().get("bold").and_then(Value::as_bool);
|
||||
assert_ne!(bold, Some(true), "the pasted text took the destination's formatting, not the copied bold");
|
||||
let italic = h.state_mut().run("format.state", json!({})).unwrap().get("italic").and_then(Value::as_bool);
|
||||
assert_eq!(italic, Some(true), "the pasted text kept the destination's italic");
|
||||
}
|
||||
|
||||
/// #1669: with Shift held, Backspace still erases (#707) and Delete cuts the selection, doing
|
||||
/// nothing without one, as in Word for Windows. (Windows sends Shift+Delete as a Cut; macOS and
|
||||
/// Linux send the key, which reached no command.)
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user