Compare commits

...
Author SHA1 Message Date
Martin BallandClaude Opus 5.5 cc125c4732 #1537: merge — restore the closing brace of the range Paste Formatting test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 09:32:02 +00:00
Martin BallandClaude Opus 5.5 c13e9a53b9 #1537: review — an expired paste wait drops the chord's noted Mod+Shift too (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 09:31:36 +00:00
Martin BallandClaude Opus 5.5 f6a4f594ea #1537: review — the wait for Mod+Shift+V's browser paste ends after a second, so a later menu-bar paste isn't dropped (FredApps)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 09:26:52 +00:00
Martin Ball dab09ef6ff Merge remote-tracking branch 'origin/main' into fix/1537-ctrl-shift-v-keep-text-only
; Conflicts:
;	crates/engine/src/tests.rs
;	docs/ui-parity.md
2026-11-08 09:21:08 +00:00
Dylan M. TaylorandMartin Ball 661a790540 Render: skip drawing paths far past every canvas before flattening (#5453)
* fix(render): skip drawing paths far past every canvas before flattening

* fix(render): refuse paths with non-finite points too

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 08:15:55 +01:00
Dylan M. TaylorandMartin Ball 04217419b6 DOCX save: collect an embedded object's relationship ids in a set (#5414)
* fix(docx): collect an embedded object's relationship ids in a set on save

* style: rustfmt

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:59:33 +01:00
Dylan M. TaylorandMartin Ball 4afff61f10 fix(geom): measure PNGs from their chunk headers, not the decoder's header read (#5459)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:45:02 +01:00
Dylan M. TaylorandMartin Ball e76293a564 fix(doc): refuse export links holding characters the writers drop (#5426)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:30:15 +01:00
Martin BallandClaude Opus 5.5 c254d7c269 #1537: review — a dropped paste uses up its modifiers; range Paste Formatting keeps the link (alehel)
- The browser paste event dropped after a formatting paste now clears paste_event_mods too, so a
  later menu-bar paste isn't read as Keep Text Only. Note the no-text clipboard case.
- Test that Paste Formatting over a selection keeps the target's hyperlink.
- session.rs: copied_format holds a Format Painter pick too.
- ROADMAP: entry moved up with the other 2026-11-04 entries; stray blank line dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:09:10 +00:00
Martin BallandClaude Opus 5.5 f8cd82b89e #1537: review — the docs say a Format Painter pick counts as picked-up formatting too (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:08:39 +00:00
Martin BallandClaude Opus 5.5 3e236af3e7 #1537: review — the text-only paste test checks the destination's own formatting stays (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:42:39 +00:00
Martin BallandClaude Opus 5.5 97c72d9063 #1537: review — an unrelated key's modifiers can't steer a later paste event
A separate paste_event_mods is set by Mod changes and Mod+V's press, and cleared by any other key, a pointer press or focus change; the release path keeps paste_mods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:24:10 +00:00
Martin BallandClaude Opus 5.5 23e40916dd #1537: review — a caret Paste Formatting keeps the target's own link and tracked marks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:58:36 +00:00
Martin BallandClaude Opus 5.5 8f9400ce78 #1537: review — shared format buffer, no tracked paragraph changes, pointer press ends the paste wait
- picked_format strips ParaProps fmt_change/num_change
- Format Painter pick is stored in copied_format too; a used-up or cancelled brush leaves it
- a pointer press clears ignore_next_paste
- docs: repeat-until-replaced wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:27:43 +00:00
Martin BallandClaude Opus 5.5 5c537a6cf4 #1537: review — one format buffer, formatting-only copy, browser paste suppression only where it exists
- ignore_next_paste armed only for Mod+Shift+V on platforms that send a paste event after it (web); never for a rebound key
- the ignored paste is dropped before equation and picture-file handling
- Event::Paste takes paste_mods so they don't leak into later pastes
- Copy Formatting and Format Painter replace each other; a used-up brush no longer hands over to older copied formatting
- copied formatting carries no hyperlink or revision marks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:52:02 +00:00
Martin BallandClaude Opus 5.5 749a0b22be #1537: review — copied formatting stays for every Mod+Shift+V; a browser's paste after it is ignored (wanikhawar)
Copy Formatting keeps its formatting apart from the one-shot Format Painter brush, so a second
Mod+Shift+V pastes formatting again instead of text, and Mod+Shift+C no longer arms the brush.
After Mod+Shift+V pastes formatting, the browser's paste event for the same keystroke is ignored;
paste events read the modifiers held in event order. Only Mod+Shift+V itself falls back to Keep
Text Only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYPZSei56X4AGFPSu5
2026-11-07 13:38:42 +00:00
Martin BallandClaude Opus 5.5 8661fa6c66 #1537: review — a browser's Mod+Shift+V paste event pastes text only, once (CodeRabbit)
On the web the key no longer pastes as well: the paste event that follows carries the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:38:33 +00:00
Martin BallandClaude Opus 5.5 e11bdc5e7b #1537: Ctrl/Cmd+Shift+V pastes text only when no formatting was copied, as in Word for Windows
With formatting picked up by Ctrl/Cmd+Shift+C it still pastes the formatting.
Closes #1537, #4943; takes over #1562.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:38:33 +00:00
19 changed files with 670 additions and 35 deletions
+5 -1
View File
@@ -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.
+10
View File
@@ -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:?}");
+25 -5
View File
@@ -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
+25 -12
View File
@@ -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())?;
}
+4
View File
@@ -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
+1
View File
@@ -250,6 +250,7 @@ impl Session {
author: _,
initials: _,
painter: _,
copied_format: _,
status: _,
last_command: _,
recording: _,
+1
View File
@@ -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,
+1
View File
@@ -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: _,
+155
View File
@@ -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`.
+93 -3
View File
@@ -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);
+1 -4
View File
@@ -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);
+13 -1
View File
@@ -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);
+15
View File
@@ -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)));
}
+16
View File
@@ -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,
+65 -4
View File
@@ -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}));
+20 -1
View File
@@ -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 };
+179
View File
@@ -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");
}
}
+39 -3
View File
@@ -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
View File
File diff suppressed because one or more lines are too long