* Compare, Restrict Editing and AutoCorrect Options dialogs (#412) - Compare / Combine Documents dialog: original (this document or a file) and revised document, label changes with, More >> comparison settings, word or character level, show changes in. review.compare honours case changes, white space, formatting (tracked formatting changes), tables (compared and kept, no longer dropped) and character level; the LCS no longer drops text past its size cap. Unsupported settings are accepted and reported. - Restrict Editing pane: formatting restrictions (locked styles, w:formatting), editing restrictions, exceptions for everyone (w:permStart/permEnd), Start Enforcing Protection with an optional password (SHA-512, salt, spin count 100000; ECMA-376 §17.15.1.29) and Stop Protection. Enforced in Session::run: outside the exceptions nothing may change; undo can't lift protection; passwords never land in Repeat/macros. DOCX round trip of protection, hash attributes, locked styles and ranges. - AutoCorrect Options dialog: replacement list (add/replace/delete, built-in entries too), capitalization options with exceptions, AutoFormat As You Type switches, Math AutoCorrect list. Saved per user in ui.json; damaged values read as defaults. Names of days are capitalised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restrict Editing: verify Word's legacy protection hash; refuse unsupported forms (#412) - Stop Protection checks Word's legacy w:documentProtection hash (w:cryptAlgorithmSid/w:hash/w:salt/w:cryptSpinCount) like Word: the password becomes the legacy 32-bit key (ECMA-376 Part 4 §14.8.1 initial code array and encryption matrix), its reversed bytes as an upper-case hex string are hashed with the salt prepended and the iteration number appended. A wrong password is rejected; the form no longer unlocks with any password. - Unsupported or damaged hashes (MD2/MD4/MD5 and other SIDs, w:algIdExt, unknown algorithmName, spin counts above 10,000,000, bad base64) refuse with "This password type isn't supported" and the protection stays on. A protection without a hash still stops without a password. - Known-answer tests: the specification's "Example" key 0x64CEED7E and an independently computed SHA-1 hash; pane message in all 9 catalogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
2ffba93b51
commit
4b5ed293d7
|
|
@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
|
||||
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
|
||||
| 2026-10-11 | Content controls (`w:sdt`) kept, shown and edited; Developer tab, Design Mode, Properties dialog (#381) |
|
||||
| 2026-10-11 | Insert › Chart (#409): column, bar, line, area, pie, doughnut and scatter charts with Chart Design (type, Edit Data, title, legend, data labels), saved as real chart parts; catalog 408/439 (92.9%) |
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bidi;
|
||||
pub mod chart;
|
||||
pub mod connector;
|
||||
pub mod control;
|
||||
pub mod chart;
|
||||
pub mod edit;
|
||||
pub mod effects;
|
||||
pub mod encoding;
|
||||
|
|
@ -419,7 +419,18 @@ pub struct Settings {
|
|||
pub footnote_pr: section::NoteProps,
|
||||
#[serde(skip_serializing_if = "section::NoteProps::is_empty")]
|
||||
pub endnote_pr: section::NoteProps,
|
||||
/// Restrict Editing, when enforced: `readOnly`, `comments`, `trackedChanges`, `forms`, or
|
||||
/// `none` when only formatting is restricted (`w:documentProtection w:edit`).
|
||||
pub protection: Option<String>,
|
||||
/// Restrict Editing limits formatting to the styles that aren't locked, and forbids direct
|
||||
/// formatting (`w:formatting="1"`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub protect_formatting: bool,
|
||||
/// The protection password's hash, as the file's `w:documentProtection` attributes
|
||||
/// (`w:algorithmName`, `w:hashValue`, `w:saltValue`, `w:spinCount`, or the older
|
||||
/// `w:cryptAlgorithmSid`, `w:hash`, `w:salt`…). Empty: no password.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub protect_hash: Vec<(String, String)>,
|
||||
/// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word
|
||||
/// 2013 and later, which places a table's border at the margin rather than its text and lets
|
||||
/// justified lines shrink their spaces to fit more text.
|
||||
|
|
@ -498,6 +509,8 @@ impl Default for Settings {
|
|||
footnote_pr: section::NoteProps::default(),
|
||||
endnote_pr: section::NoteProps::default(),
|
||||
protection: None,
|
||||
protect_formatting: false,
|
||||
protect_hash: Vec::new(),
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
|
|
@ -1118,6 +1131,47 @@ impl Document {
|
|||
self.custom_props.len() != n
|
||||
}
|
||||
|
||||
/// Ranges that stay editable while the document is protected (Restrict Editing exceptions),
|
||||
/// in document order across every story: the [`InlineObject::PermStart`]'s id, group and
|
||||
/// editor, its start (just after the marker) and its end (at the matching
|
||||
/// [`InlineObject::PermEnd`], or the end of the story when it has none).
|
||||
pub fn perm_ranges(&self) -> Vec<PermRange> {
|
||||
let mut out: Vec<PermRange> = Vec::new();
|
||||
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k)));
|
||||
for story in stories {
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::PermStart { id, group, editor }) => {
|
||||
let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() };
|
||||
open.push(out.len());
|
||||
out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start });
|
||||
}
|
||||
Some(InlineObject::PermEnd { id }) => {
|
||||
if let Some(k) = open.iter().rposition(|i| out.get(*i).is_some_and(|r| r.id == *id)) {
|
||||
let i = open.remove(k);
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = Pos { story, path: path.clone(), off };
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A range left open runs to the end of its story.
|
||||
let end = self.end_of(story);
|
||||
for i in open {
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = end.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bookmark names in the body, in order.
|
||||
pub fn bookmarks(&self) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
|
|
@ -1134,6 +1188,25 @@ impl Document {
|
|||
}
|
||||
}
|
||||
|
||||
/// A range that stays editable while the document is protected; see [`Document::perm_ranges`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PermRange {
|
||||
pub id: u32,
|
||||
/// Editing group (`everyone`, `editors`…); empty when the range names an editor.
|
||||
pub group: String,
|
||||
/// The one editor the range is for; empty for a group.
|
||||
pub editor: String,
|
||||
pub start: Pos,
|
||||
pub end: Pos,
|
||||
}
|
||||
|
||||
impl PermRange {
|
||||
/// Can everyone edit it (rather than one person or group)?
|
||||
pub fn everyone(&self) -> bool {
|
||||
self.group.eq_ignore_ascii_case("everyone")
|
||||
}
|
||||
}
|
||||
|
||||
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
|
||||
pub fn count_words(s: &str) -> usize {
|
||||
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
|
|
|
|||
|
|
@ -484,6 +484,20 @@ pub enum InlineObject {
|
|||
CommentEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// The start of a range that stays editable while the document is protected (Restrict
|
||||
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
|
||||
/// group, or one editor (`editor`, a user name or address).
|
||||
PermStart {
|
||||
id: u32,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
group: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
editor: String,
|
||||
},
|
||||
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
|
||||
PermEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
|
||||
/// A display equation sits on a line of its own.
|
||||
Equation {
|
||||
|
|
@ -652,6 +666,8 @@ impl InlineObject {
|
|||
| InlineObject::BookmarkEnd { .. }
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermStart { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::FieldStart { .. }
|
||||
| InlineObject::FieldEnd
|
||||
| InlineObject::ControlStart { .. }
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ pub struct Style {
|
|||
pub hidden: bool,
|
||||
/// Built in (cannot be deleted).
|
||||
pub builtin: bool,
|
||||
/// Can't be applied while Restrict Editing limits formatting to a selection of styles
|
||||
/// (`w:locked`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub locked: bool,
|
||||
/// Table style: conditional formatting for header row, banding etc.
|
||||
pub table: Option<TableStyleParts>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,6 +223,175 @@ pub fn check_password(password: &str) -> Result<(), DocxError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Restrict Editing's password (`w:documentProtection`, ECMA-376 Part 1 §17.15.1.29): the
|
||||
/// attributes that store its hash — SHA-512 of a random 16-byte salt and the password as UTF-16LE,
|
||||
/// hashed again `spin_count` times with the iteration number appended ([MS-OFFCRYPTO] §2.4.2.4,
|
||||
/// the ISO write-protection method). Protection isn't encryption: the document stays readable,
|
||||
/// the password only guards turning protection off.
|
||||
pub fn protection_hash(password: &str, spin_count: u32) -> Result<Vec<(String, String)>, DocxError> {
|
||||
check_password(password)?;
|
||||
let spin_count = spin_count.min(MAX_SPIN_COUNT);
|
||||
let salt = random(SALT)?;
|
||||
let hash = iterate_appended(Hash::Sha512, &salt, password, spin_count);
|
||||
let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
|
||||
Ok(vec![
|
||||
("w:algorithmName".into(), "SHA-512".into()),
|
||||
("w:hashValue".into(), b64(&hash)),
|
||||
("w:saltValue".into(), b64(&salt)),
|
||||
("w:spinCount".into(), spin_count.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
/// What checking a Restrict Editing password found ([`check_protection_password`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordCheck {
|
||||
/// The protection has no password: anyone may stop it.
|
||||
NoPassword,
|
||||
/// The password matches the stored hash.
|
||||
Correct,
|
||||
/// The password doesn't match.
|
||||
Incorrect,
|
||||
/// The hash uses an algorithm or form WordCraft can't check, or is damaged. The protection
|
||||
/// must stay on: guessing would let any password through.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Does `password` match a Restrict Editing hash (attributes as [`protection_hash`] makes them, or
|
||||
/// as read from a file)? Two forms are checked:
|
||||
///
|
||||
/// - `w:algorithmName` / `w:hashValue` / `w:saltValue` / `w:spinCount`: the password itself is
|
||||
/// hashed ([`iterate_appended`]).
|
||||
/// - `w:cryptAlgorithmSid` / `w:hash` / `w:salt` / `w:cryptSpinCount` (ECMA-376 Part 4 §14.8.1):
|
||||
/// the password first becomes Word's legacy 32-bit key ([`legacy_key`]), whose bytes, reversed
|
||||
/// and written as an upper-case hex string, are hashed the same way.
|
||||
///
|
||||
/// Anything else — an algorithm other than SHA-1/SHA-256/SHA-384/SHA-512, an application-defined
|
||||
/// algorithm (`w:algIdExt`), a class other than `hash`, a spin count above the 10,000,000 Word
|
||||
/// accepts, damaged values — is [`PasswordCheck::Unsupported`], never a match.
|
||||
pub fn check_protection_password(attrs: &[(String, String)], password: &str) -> PasswordCheck {
|
||||
let get = |k: &str| attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.trim());
|
||||
if get("w:hashValue").is_some() {
|
||||
let Some(hash) = get("w:algorithmName").and_then(Hash::parse) else { return PasswordCheck::Unsupported };
|
||||
return verify(hash, get("w:hashValue"), get("w:saltValue"), get("w:spinCount"), password);
|
||||
}
|
||||
if get("w:hash").is_none() {
|
||||
// §17.15.1.29: without a hash no password is associated with the protection.
|
||||
return PasswordCheck::NoPassword;
|
||||
}
|
||||
let class_ok = get("w:cryptAlgorithmClass").is_none_or(|c| c == "hash");
|
||||
let type_ok = get("w:cryptAlgorithmType").is_none_or(|t| t == "typeAny");
|
||||
if !class_ok || !type_ok || get("w:algIdExt").is_some() {
|
||||
return PasswordCheck::Unsupported;
|
||||
}
|
||||
// Part 4 §14.8.1 lists 1 MD2, 2 MD4, 3 MD5, 4 SHA-1, 12 SHA-256, 13 SHA-384, 14 SHA-512.
|
||||
let hash = match get("w:cryptAlgorithmSid").and_then(|v| v.parse::<u32>().ok()) {
|
||||
Some(4) => Hash::Sha1,
|
||||
Some(12) => Hash::Sha256,
|
||||
Some(13) => Hash::Sha384,
|
||||
Some(14) => Hash::Sha512,
|
||||
_ => return PasswordCheck::Unsupported,
|
||||
};
|
||||
verify(hash, get("w:hash"), get("w:salt"), get("w:cryptSpinCount"), &legacy_key_hex(password))
|
||||
}
|
||||
|
||||
/// Hash `input` with the stored salt and spin count and compare with the stored hash. Damaged or
|
||||
/// out-of-range values are [`PasswordCheck::Unsupported`].
|
||||
fn verify(hash: Hash, want: Option<&str>, salt: Option<&str>, spin_count: Option<&str>, input: &str) -> PasswordCheck {
|
||||
let dec = |v: &str| base64::engine::general_purpose::STANDARD.decode(v).ok();
|
||||
let Some(want) = want.and_then(dec) else { return PasswordCheck::Unsupported };
|
||||
// §17.15.1.29: without a salt nothing is prepended.
|
||||
let salt = match salt {
|
||||
Some(v) => match dec(v) {
|
||||
Some(s) => s,
|
||||
None => return PasswordCheck::Unsupported,
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
// Word accepts spin counts from 0 to 10,000,000 ([MS-OI29500] 2.1.402 d); a missing one is 0.
|
||||
let spin_count = match spin_count.map(str::parse::<u32>) {
|
||||
None => 0,
|
||||
Some(Ok(n)) if n <= MAX_SPIN_COUNT => n,
|
||||
Some(_) => return PasswordCheck::Unsupported,
|
||||
};
|
||||
if want.len() != hash.size() || salt.len() > 1024 {
|
||||
return PasswordCheck::Unsupported;
|
||||
}
|
||||
if same(&iterate_appended(hash, &salt, input, spin_count), &want) { PasswordCheck::Correct } else { PasswordCheck::Incorrect }
|
||||
}
|
||||
|
||||
/// The initial value of the legacy key's high-order word, by password length 1–15 (ECMA-376
|
||||
/// Part 4 §14.8.1, "Initial code array").
|
||||
const LEGACY_INITIAL_CODE: [u16; 15] =
|
||||
[0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3];
|
||||
|
||||
/// The legacy key's encryption matrix (Part 4 §14.8.1): one row per character position, rows
|
||||
/// "Last-14" to "Last", one column per bit 0–6. The password's last character uses the last row.
|
||||
const LEGACY_MATRIX: [[u16; 7]; 15] = [
|
||||
[0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09],
|
||||
[0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF],
|
||||
[0x4563, 0x8AC6, 0x05AD, 0x0B5A, 0x16B4, 0x2D68, 0x5AD0],
|
||||
[0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40],
|
||||
[0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5],
|
||||
[0x6F45, 0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A],
|
||||
[0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9],
|
||||
[0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4, 0x7B68, 0xF6D0],
|
||||
[0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC],
|
||||
[0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10],
|
||||
[0xAA51, 0x4483, 0x8906, 0x022D, 0x045A, 0x08B4, 0x1168],
|
||||
[0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C],
|
||||
[0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6, 0x1DAD],
|
||||
[0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC],
|
||||
[0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4],
|
||||
];
|
||||
|
||||
/// Word's legacy 32-bit password key (ECMA-376 Part 4 §14.8.1): the password as UTF-16 without a
|
||||
/// leading BOM, cut to 15 characters, each character reduced to one byte (its low byte, or its
|
||||
/// high byte when the low byte is 0); the high-order word comes from the initial code array and
|
||||
/// the encryption matrix, the low-order word from rotating and XOR-ing the bytes backwards.
|
||||
fn legacy_key(password: &str) -> u32 {
|
||||
let mut units = password.encode_utf16().peekable();
|
||||
units.next_if_eq(&0xFEFF);
|
||||
let bytes: Zeroizing<Vec<u8>> =
|
||||
Zeroizing::new(units.take(LEGACY_INITIAL_CODE.len()).map(|u| if u & 0xFF != 0 { (u & 0xFF) as u8 } else { (u >> 8) as u8 }).collect());
|
||||
let Some(&initial) = bytes.len().checked_sub(1).and_then(|i| LEGACY_INITIAL_CODE.get(i)) else { return 0 };
|
||||
let mut high = initial;
|
||||
let first_row = LEGACY_MATRIX.len() - bytes.len();
|
||||
for (row, &c) in LEGACY_MATRIX.iter().skip(first_row).zip(bytes.iter()) {
|
||||
for (bit, &code) in row.iter().enumerate() {
|
||||
if c & (1 << bit) != 0 {
|
||||
high ^= code;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rotate = |w: u16| ((w >> 14) & 0x0001) | ((w << 1) & 0x7FFF);
|
||||
let mut low = 0u16;
|
||||
for &c in bytes.iter().rev() {
|
||||
low = rotate(low) ^ u16::from(c);
|
||||
}
|
||||
// `bytes` holds at most 15 values, so its length fits a word.
|
||||
low = rotate(low) ^ (bytes.len() as u16) ^ 0xCE4B;
|
||||
(u32::from(high) << 16) | u32::from(low)
|
||||
}
|
||||
|
||||
/// What the legacy form hashes: the key's bytes in reversed order (Part 4 §14.8.1: 0x64CEED7E
|
||||
/// becomes 7EEDCE64), as an upper-case hex string — hashed as UTF-16LE like a password, as Word
|
||||
/// does ([MS-OE376] 2.1.410 a).
|
||||
fn legacy_key_hex(password: &str) -> Zeroizing<String> {
|
||||
let key = legacy_key(password);
|
||||
Zeroizing::new(key.to_le_bytes().iter().map(|b| format!("{b:02X}")).collect())
|
||||
}
|
||||
|
||||
/// The ISO write-protection hash ([MS-OFFCRYPTO] §2.4.2.4): H0 = H(salt + password as UTF-16LE),
|
||||
/// Hn = H(Hn-1 + iterator) — the iterator after the hash, unlike encryption's [`iterate`].
|
||||
fn iterate_appended(hash: Hash, salt: &[u8], password: &str, spin_count: u32) -> Zeroizing<Vec<u8>> {
|
||||
let pw: Zeroizing<Vec<u8>> = Zeroizing::new(password.encode_utf16().flat_map(u16::to_le_bytes).collect());
|
||||
let mut h = Zeroizing::new(hash.hash(&[salt, &pw]));
|
||||
for i in 0..spin_count.min(MAX_SPIN_COUNT) {
|
||||
*h = hash.hash(&[&h, &i.to_le_bytes()]);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn bad(msg: impl Into<String>) -> DocxError {
|
||||
DocxError::Encryption(msg.into())
|
||||
}
|
||||
|
|
@ -626,6 +795,72 @@ fn primary_transform() -> Vec<u8> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn protection_password_round_trip() {
|
||||
use super::PasswordCheck::*;
|
||||
let attrs = super::protection_hash("s3cret", 1000).unwrap();
|
||||
assert_eq!(super::check_protection_password(&attrs, "s3cret"), Correct);
|
||||
assert_eq!(super::check_protection_password(&attrs, "S3cret"), Incorrect);
|
||||
// Damaged attributes are refused, never accepted.
|
||||
let mut bad = attrs.clone();
|
||||
bad.retain(|(k, _)| k != "w:hashValue");
|
||||
bad.push(("w:hashValue".into(), "not base64!".into()));
|
||||
assert_eq!(super::check_protection_password(&bad, "s3cret"), Unsupported);
|
||||
let mut odd = attrs.clone();
|
||||
odd.retain(|(k, _)| k != "w:algorithmName");
|
||||
odd.push(("w:algorithmName".into(), "WHIRLPOOL".into()));
|
||||
assert_eq!(super::check_protection_password(&odd, "s3cret"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&[("w:cryptProviderType".into(), "rsaAES".into())], "x"), NoPassword);
|
||||
}
|
||||
|
||||
/// The worked example of ECMA-376 Part 4 §14.8.1: "Example" gives the key 0x64CEED7E, hashed
|
||||
/// as "7EEDCE64".
|
||||
#[test]
|
||||
fn legacy_key_matches_the_specification_example() {
|
||||
assert_eq!(super::legacy_key("Example"), 0x64CE_ED7E);
|
||||
assert_eq!(super::legacy_key_hex("Example").as_str(), "7EEDCE64");
|
||||
assert_eq!(super::legacy_key(""), 0);
|
||||
// A leading BOM is dropped; only the first 15 characters count.
|
||||
assert_eq!(super::legacy_key("\u{FEFF}Example"), 0x64CE_ED7E);
|
||||
assert_eq!(super::legacy_key("abcdefghijklmnopq"), super::legacy_key("abcdefghijklmno"));
|
||||
assert_ne!(super::legacy_key("abcdefghijklmno"), super::legacy_key("abcdefghijklmn"));
|
||||
}
|
||||
|
||||
/// The legacy form (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`), as Word
|
||||
/// writes it. The expected hash was computed independently from the specification's stages.
|
||||
#[test]
|
||||
fn legacy_protection_hash_is_verified() {
|
||||
use super::PasswordCheck::*;
|
||||
let legacy = |sid: &str, hash: &str, spin: &str| -> Vec<(String, String)> {
|
||||
vec![
|
||||
("w:cryptProviderType".into(), "rsaFull".into()),
|
||||
("w:cryptAlgorithmClass".into(), "hash".into()),
|
||||
("w:cryptAlgorithmType".into(), "typeAny".into()),
|
||||
("w:cryptAlgorithmSid".into(), sid.into()),
|
||||
("w:cryptSpinCount".into(), spin.into()),
|
||||
("w:hash".into(), hash.into()),
|
||||
("w:salt".into(), "AAECAwQFBgcICQoLDA0ODw==".into()),
|
||||
]
|
||||
};
|
||||
let attrs = legacy("4", KNOWN_LEGACY_SHA1, "100000");
|
||||
assert_eq!(super::check_protection_password(&attrs, "Example"), Correct);
|
||||
assert_eq!(super::check_protection_password(&attrs, "example"), Incorrect);
|
||||
assert_eq!(super::check_protection_password(&attrs, ""), Incorrect);
|
||||
// MD5 (and the other algorithms WordCraft lacks), hostile spin counts: refused.
|
||||
assert_eq!(super::check_protection_password(&legacy("3", KNOWN_LEGACY_SHA1, "100000"), "Example"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "4294967295"), "Example"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "-1"), "Example"), Unsupported);
|
||||
let mut ext = attrs.clone();
|
||||
ext.push(("w:algIdExt".into(), "0000000A".into()));
|
||||
assert_eq!(super::check_protection_password(&ext, "Example"), Unsupported);
|
||||
// No algorithm named: the very old bare-hash form isn't defined, so it's refused.
|
||||
let bare = vec![("w:hash".to_string(), KNOWN_LEGACY_SHA1.to_string())];
|
||||
assert_eq!(super::check_protection_password(&bare, "Example"), Unsupported);
|
||||
}
|
||||
|
||||
/// SHA-1 of the salt 00 01 … 0F and "7EEDCE64" as UTF-16LE, iterated 100,000 times.
|
||||
const KNOWN_LEGACY_SHA1: &str = "pEWelKoH6SVpvMmyPAJ8bHA/g/s=";
|
||||
|
||||
use super::*;
|
||||
|
||||
fn zip_package() -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ mod write;
|
|||
mod xml;
|
||||
|
||||
pub use chart_spec::{chart_items, chart_xml};
|
||||
pub use crypt::{DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, check_password, decrypt, encrypt, encrypt_with_spin_count, is_encrypted};
|
||||
pub use crypt::{
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
|
||||
is_encrypted, protection_hash,
|
||||
};
|
||||
pub use read::read;
|
||||
pub use write::{write, write_as};
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ pub(crate) struct Reader<'p> {
|
|||
pub comment_map: HashMap<String, u32>,
|
||||
pub comments_ended: HashSet<u32>,
|
||||
pub bookmarks: HashMap<String, String>,
|
||||
/// Restrict Editing exception ranges (`w:permStart`): the file's id → ours.
|
||||
pub perms: HashMap<String, u32>,
|
||||
/// Chart and diagram parts by path, parsed once.
|
||||
graphic_parts: HashMap<String, Option<Arc<El>>>,
|
||||
/// Charts and diagrams by (kind, part path, width bits, height bits), built once.
|
||||
|
|
@ -122,6 +124,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
|||
comment_map: HashMap::new(),
|
||||
comments_ended: HashSet::new(),
|
||||
bookmarks: HashMap::new(),
|
||||
perms: HashMap::new(),
|
||||
graphic_parts: HashMap::new(),
|
||||
graphics: HashMap::new(),
|
||||
graphic_budget: MAX_GRAPHIC_WORK,
|
||||
|
|
@ -621,6 +624,7 @@ impl Reader<'_> {
|
|||
quick: flag(s, "w:qFormat").unwrap_or(false),
|
||||
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
|
||||
locked: flag(s, "w:locked").unwrap_or(false),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(p) = s.child("w:pPr") {
|
||||
|
|
@ -860,12 +864,7 @@ impl Reader<'_> {
|
|||
}
|
||||
s.endnote_pr = props::note_pr(k, false);
|
||||
}
|
||||
"w:documentProtection" => {
|
||||
let enforced = k.attr("w:enforcement").is_some_and(|v| !matches!(v, "0" | "false" | "off"));
|
||||
if enforced && let Some(e) = k.attr("w:edit") {
|
||||
s.protection = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
"w:documentProtection" => read_protection(k, s),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1028,3 +1027,46 @@ impl Reader<'_> {
|
|||
fn default_level(i: usize) -> Level {
|
||||
Level { indent: 36.0 * (i as f32 + 1.0), text: String::new(), ..Level::default() }
|
||||
}
|
||||
|
||||
/// The password hash attributes of `w:documentProtection` that are kept (ECMA-376 §17.15.1.29:
|
||||
/// the current `algorithmName`/`hashValue`/`saltValue`/`spinCount`, and the older transitional
|
||||
/// `crypt*`/`hash`/`salt` set).
|
||||
pub(crate) const PROTECTION_HASH_ATTRS: [&str; 16] = [
|
||||
"w:algorithmName",
|
||||
"w:hashValue",
|
||||
"w:saltValue",
|
||||
"w:spinCount",
|
||||
"w:cryptProviderType",
|
||||
"w:cryptAlgorithmClass",
|
||||
"w:cryptAlgorithmType",
|
||||
"w:cryptAlgorithmSid",
|
||||
"w:cryptSpinCount",
|
||||
"w:cryptProvider",
|
||||
"w:algIdExt",
|
||||
"w:algIdExtSource",
|
||||
"w:cryptProviderTypeExt",
|
||||
"w:cryptProviderTypeExtSource",
|
||||
"w:hash",
|
||||
"w:salt",
|
||||
];
|
||||
|
||||
/// Restrict Editing (`w:documentProtection`, §17.15.1.29): the editing restriction, whether
|
||||
/// formatting is limited to unlocked styles, and the password hash. Only enforced protection is
|
||||
/// kept; a hostile attribute value can't be longer than a hash needs.
|
||||
fn read_protection(k: &El, s: &mut wordcraft_doc::Settings) {
|
||||
let on = |v: &str| !matches!(v, "0" | "false" | "off");
|
||||
if !k.attr("w:enforcement").is_some_and(on) {
|
||||
return;
|
||||
}
|
||||
let edit = k.attr("w:edit").filter(|e| matches!(*e, "readOnly" | "comments" | "trackedChanges" | "forms"));
|
||||
let formatting = k.attr("w:formatting").is_some_and(on);
|
||||
if edit.is_none() && !formatting {
|
||||
return;
|
||||
}
|
||||
s.protection = Some(edit.unwrap_or("none").to_string());
|
||||
s.protect_formatting = formatting;
|
||||
s.protect_hash = PROTECTION_HASH_ATTRS
|
||||
.iter()
|
||||
.filter_map(|n| k.attr(n).filter(|v| !v.is_empty() && v.len() <= 1024).map(|v| (n.to_string(), v.to_string())))
|
||||
.collect();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ impl Reader<'_> {
|
|||
self.read_blocks(sc, c, rels, out, depth + 1);
|
||||
}
|
||||
}
|
||||
"w:bookmarkStart" | "w:bookmarkEnd" | "w:commentRangeStart" | "w:commentRangeEnd" => {
|
||||
"w:bookmarkStart" | "w:bookmarkEnd" | "w:commentRangeStart" | "w:commentRangeEnd" | "w:permStart" | "w:permEnd" => {
|
||||
if let Some(o) = self.marker(e) {
|
||||
sc.pending.push(o);
|
||||
}
|
||||
|
|
@ -280,6 +280,21 @@ impl Reader<'_> {
|
|||
}
|
||||
"w:bookmarkEnd" => self.bookmarks.get(&id).map(|n| InlineObject::BookmarkEnd { name: n.clone() }),
|
||||
"w:commentRangeStart" => self.comment_id(&id).map(|id| InlineObject::CommentStart { id }),
|
||||
"w:permStart" => {
|
||||
// Bounded: a hostile file can't grow the map without end.
|
||||
if self.perms.len() >= 100_000 {
|
||||
return None;
|
||||
}
|
||||
let n = self.perms.len() as u32;
|
||||
let id = *self.perms.entry(id).or_insert(n);
|
||||
let text = |a: &str| e.attr(a).filter(|v| v.len() <= 256).unwrap_or("").to_string();
|
||||
let (group, editor) = (text("w:edGrp"), text("w:ed"));
|
||||
if group.is_empty() && editor.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::PermStart { id, group, editor })
|
||||
}
|
||||
"w:permEnd" => self.perms.get(&id).map(|id| InlineObject::PermEnd { id: *id }),
|
||||
"w:commentRangeEnd" => {
|
||||
let cid = self.comment_id(&id)?;
|
||||
self.comments_ended.insert(cid);
|
||||
|
|
@ -428,7 +443,7 @@ impl Reader<'_> {
|
|||
self.read_inline_children(sc, pb, k, rels, ctx, depth + 1);
|
||||
self.end_field(sc, pb);
|
||||
}
|
||||
"w:bookmarkStart" | "w:bookmarkEnd" | "w:commentRangeStart" | "w:commentRangeEnd" => {
|
||||
"w:bookmarkStart" | "w:bookmarkEnd" | "w:commentRangeStart" | "w:commentRangeEnd" | "w:permStart" | "w:permEnd" => {
|
||||
if let Some(o) = self.marker(k) {
|
||||
let props = self.run_props_none(ctx);
|
||||
// Markers go straight into the paragraph (never into a field's cached result).
|
||||
|
|
|
|||
|
|
@ -677,6 +677,9 @@ fn style_xml(w: &mut W, st: &Style) {
|
|||
if st.quick {
|
||||
w.empty("w:qFormat", &[]);
|
||||
}
|
||||
if st.locked {
|
||||
w.empty("w:locked", &[]);
|
||||
}
|
||||
if !st.para.is_empty() && st.kind != StyleKind::Character {
|
||||
w.open("w:pPr", &[]);
|
||||
props::ppr_inner(w, &st.para, false, None);
|
||||
|
|
@ -860,7 +863,21 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
|
|||
w.empty("w:trackRevisions", &[]);
|
||||
}
|
||||
if let Some(p) = &s.protection {
|
||||
w.empty("w:documentProtection", &[("w:edit", p), ("w:enforcement", "1")]);
|
||||
let mut a: Vec<(&str, &str)> = Vec::new();
|
||||
if p != "none" {
|
||||
a.push(("w:edit", p));
|
||||
}
|
||||
if s.protect_formatting {
|
||||
a.push(("w:formatting", "1"));
|
||||
}
|
||||
a.push(("w:enforcement", "1"));
|
||||
// Only the hash attributes the reader keeps, each once.
|
||||
for name in crate::read::PROTECTION_HASH_ATTRS {
|
||||
if let Some((_, v)) = s.protect_hash.iter().find(|(k, _)| k == name) {
|
||||
a.push((name, v));
|
||||
}
|
||||
}
|
||||
w.empty("w:documentProtection", &a);
|
||||
}
|
||||
w.val("w:defaultTabStop", &crate::units::twips(s.default_tab.clamp(1.0, 1584.0)));
|
||||
if s.auto_hyphenation {
|
||||
|
|
|
|||
|
|
@ -440,6 +440,18 @@ impl Writer<'_> {
|
|||
let id = self.bookmark_id(name);
|
||||
w.empty("w:bookmarkEnd", &[("w:id", &id)]);
|
||||
}
|
||||
InlineObject::PermStart { id, group, editor } => {
|
||||
let id = id.to_string();
|
||||
let mut a = vec![("w:id", id.as_str())];
|
||||
if !group.is_empty() {
|
||||
a.push(("w:edGrp", group.as_str()));
|
||||
}
|
||||
if !editor.is_empty() {
|
||||
a.push(("w:ed", editor.as_str()));
|
||||
}
|
||||
w.empty("w:permStart", &a);
|
||||
}
|
||||
InlineObject::PermEnd { id } => w.empty("w:permEnd", &[("w:id", &id.to_string())]),
|
||||
InlineObject::CommentStart { id } => {
|
||||
if self.doc.comments.contains_key(id) {
|
||||
w.empty("w:commentRangeStart", &[("w:id", &id.to_string())]);
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ fn styles_and_lists_round_trip() {
|
|||
quick: true,
|
||||
hidden: false,
|
||||
builtin: false,
|
||||
locked: true,
|
||||
table: None,
|
||||
});
|
||||
d.styles.upsert(Style {
|
||||
|
|
|
|||
|
|
@ -14,12 +14,18 @@ fn has_sel(s: &Session) -> Option<&'static str> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
|
||||
if s.step_lifts_protection(false) {
|
||||
return Err(CmdError::Disabled("edit.undo: use Stop Protection to lift the document's protection".into()));
|
||||
}
|
||||
s.undo();
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+Z")
|
||||
.pure(),
|
||||
CommandSpec::new("edit.redo", "Redo", "Quick Access Toolbar", |s, _| {
|
||||
if s.step_lifts_protection(true) {
|
||||
return Err(CmdError::Disabled("edit.redo: use Stop Protection to lift the document's protection".into()));
|
||||
}
|
||||
s.redo();
|
||||
sel_result(s)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -182,6 +182,13 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted
|
||||
/// differently from `old` as tracked formatting changes by `author`.
|
||||
pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec<Revision>, author: &str, date: &str) {
|
||||
let mut r = Recorder { author: author.to_string(), date: date.to_string(), before: &[], revisions, rid: None };
|
||||
r.para(old, new);
|
||||
}
|
||||
|
||||
/// The properties a change sets, as the Style Inspector lists them: `[{"prop", "value"}]` in
|
||||
/// field order; a switch turned off is `false`, a value removed is `null`.
|
||||
pub fn diff<T: Serialize>(old: &T, new: &T) -> Vec<Value> {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod page;
|
|||
pub mod para;
|
||||
pub mod paste;
|
||||
pub mod phonetic;
|
||||
pub mod protect;
|
||||
pub mod references;
|
||||
pub mod review;
|
||||
pub mod speech;
|
||||
|
|
@ -80,6 +81,7 @@ pub fn registry() -> Registry {
|
|||
v.extend(wordart::specs());
|
||||
v.extend(connectors::specs());
|
||||
v.extend(tools::specs());
|
||||
v.extend(protect::specs());
|
||||
v.extend(speech::specs());
|
||||
v.extend(controls::specs());
|
||||
Registry::new(v)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
//! Restrict Editing (Review › Protect, #412): editing restrictions (read only, comments, tracked
|
||||
//! changes, filling in forms), formatting limited to a selection of styles, exceptions — ranges
|
||||
//! everyone may still edit (`w:permStart w:edGrp="everyone"`) — and an optional password that
|
||||
//! guards Stop Protection (ECMA-376 §17.15.1.29; the hash is in `wordcraft_docx`).
|
||||
//!
|
||||
//! Enforcement lives in [`crate::Session::run`]: [`check_before`] refuses commands the
|
||||
//! restrictions forbid outright, and while exceptions exist [`outside_unchanged`] checks after the
|
||||
//! command that nothing outside them changed (the command is rolled back otherwise).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::{InlineObject, OBJ};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, PermRange, Pos, StoryRef};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The editing restrictions `w:edit` names.
|
||||
const MODES: [&str; 5] = ["none", "readOnly", "comments", "trackedChanges", "forms"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("review.restrict", "Restrict Editing", "Review › Protect", restrict).params(
|
||||
r#"{"mode"?: "none|readOnly|comments|trackedChanges|forms" (start enforcing; "none" stops), "formatting"?: bool (limit formatting to unlocked styles), "lockedStyles"?: [style id or name] (the styles that can't be applied; every other style is unlocked), "password"?: string (set when enforcing; required to stop a password-protected document), "stop"?: bool, "exception"?: "add" (the selection stays editable for everyone) | "remove" (exceptions touching the selection) | "removeAll"} → state"#,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The protection state, as `review.restrict` returns it and the Restrict Editing pane shows it.
|
||||
pub fn state(s: &Session) -> Value {
|
||||
let st = &s.doc.settings;
|
||||
let regions: Vec<Value> =
|
||||
s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| json!({"start": r.start, "end": r.end})).collect();
|
||||
json!({
|
||||
"protection": st.protection,
|
||||
"formatting": st.protect_formatting,
|
||||
"password": !st.protect_hash.is_empty(),
|
||||
"lockedStyles": s.doc.styles.styles.iter().filter(|x| x.locked).map(|x| x.id.clone()).collect::<Vec<_>>(),
|
||||
"exceptions": regions.len(),
|
||||
"regions": regions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is the document's editing or formatting restricted?
|
||||
pub fn enforced(doc: &Document) -> bool {
|
||||
doc.settings.protection.is_some()
|
||||
}
|
||||
|
||||
/// May `author` edit inside the range?
|
||||
fn editable_by(r: &PermRange, author: &str) -> bool {
|
||||
r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim()))
|
||||
}
|
||||
|
||||
fn restrict(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(e) = p::str(v, "exception") {
|
||||
if enforced(&s.doc) {
|
||||
return Err(CmdError::Disabled("stop protection before changing the exceptions".into()));
|
||||
}
|
||||
match e {
|
||||
"add" => add_exception(s)?,
|
||||
"remove" => remove_exceptions(s, false)?,
|
||||
"removeAll" => remove_exceptions(s, true)?,
|
||||
_ => return Err(CmdError::Params("`exception` is \"add\", \"remove\" or \"removeAll\"".into())),
|
||||
}
|
||||
return Ok(state(s));
|
||||
}
|
||||
if let Some(list) = v.get("lockedStyles") {
|
||||
let Some(list) = list.as_array() else { return Err(CmdError::Params("`lockedStyles` is a list of style ids or names".into())) };
|
||||
if enforced(&s.doc) {
|
||||
return Err(CmdError::Disabled("stop protection before changing the formatting restrictions".into()));
|
||||
}
|
||||
let names: Vec<&str> = list.iter().filter_map(Value::as_str).take(10_000).collect();
|
||||
for st in &mut s.doc.styles.styles {
|
||||
st.locked = names.iter().any(|n| *n == st.id || n.eq_ignore_ascii_case(&st.name));
|
||||
}
|
||||
}
|
||||
let mode = p::str(v, "mode");
|
||||
if let Some(m) = mode
|
||||
&& !MODES.contains(&m)
|
||||
{
|
||||
return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms")));
|
||||
}
|
||||
let formatting = p::bool(v, "formatting");
|
||||
let stop = p::bool(v, "stop") == Some(true) || (mode == Some("none") && formatting != Some(true));
|
||||
if stop {
|
||||
return stop_protection(s, p::str(v, "password").unwrap_or(""));
|
||||
}
|
||||
if mode.is_none() && formatting.is_none() {
|
||||
// Only the locked styles changed (or nothing): report the state.
|
||||
return Ok(state(s));
|
||||
}
|
||||
if enforced(&s.doc) && !s.doc.settings.protect_hash.is_empty() {
|
||||
return Err(CmdError::Disabled("the document is protected with a password: stop protection first".into()));
|
||||
}
|
||||
let formatting = formatting.unwrap_or(false);
|
||||
let mode = mode.unwrap_or("none");
|
||||
if mode == "none" && !formatting {
|
||||
return stop_protection(s, "");
|
||||
}
|
||||
let hash = match p::str(v, "password").filter(|p| !p.is_empty()) {
|
||||
Some(pw) => wordcraft_docx::protection_hash(pw, wordcraft_docx::DEFAULT_SPIN_COUNT).map_err(|e| CmdError::Params(e.to_string()))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let st = &mut s.doc.settings;
|
||||
st.protection = Some(mode.to_string());
|
||||
st.protect_formatting = formatting;
|
||||
st.protect_hash = hash;
|
||||
if mode == "trackedChanges" {
|
||||
st.track_changes = true;
|
||||
}
|
||||
Ok(state(s))
|
||||
}
|
||||
|
||||
/// The error Stop Protection reports when the document's password hash uses an algorithm or form
|
||||
/// WordCraft can't check; the protection stays on.
|
||||
pub const UNSUPPORTED_PASSWORD: &str = "This password type isn't supported";
|
||||
|
||||
/// Stop Protection: with the document's password when it has one. The password is checked like
|
||||
/// Word checks it — the current hash form and the legacy one (ECMA-376 Part 4 §14.8.1); a hash
|
||||
/// WordCraft can't check refuses ([`UNSUPPORTED_PASSWORD`]) rather than unlocking.
|
||||
fn stop_protection(s: &mut Session, password: &str) -> CmdResult {
|
||||
if enforced(&s.doc) {
|
||||
use wordcraft_docx::PasswordCheck;
|
||||
match wordcraft_docx::check_protection_password(&s.doc.settings.protect_hash, password) {
|
||||
PasswordCheck::NoPassword | PasswordCheck::Correct => {}
|
||||
PasswordCheck::Incorrect if password.is_empty() => {
|
||||
return Err(CmdError::Params("type the document's password to stop protection".into()));
|
||||
}
|
||||
PasswordCheck::Incorrect => return Err(CmdError::Params("the password is incorrect".into())),
|
||||
PasswordCheck::Unsupported => return Err(CmdError::Disabled(UNSUPPORTED_PASSWORD.into())),
|
||||
}
|
||||
}
|
||||
let st = &mut s.doc.settings;
|
||||
st.protection = None;
|
||||
st.protect_formatting = false;
|
||||
st.protect_hash.clear();
|
||||
Ok(state(s))
|
||||
}
|
||||
|
||||
/// Make the selection an exception everyone may edit.
|
||||
fn add_exception(s: &mut Session) -> Result<(), CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b || a.story != b.story {
|
||||
return Err(CmdError::Params("select the text that stays editable".into()));
|
||||
}
|
||||
let id = s.doc.perm_ranges().iter().map(|r| r.id).max().map_or(0, |m| m.saturating_add(1));
|
||||
let props = CharProps::default();
|
||||
// The end first: inserting it can't move the start.
|
||||
s.doc.insert_object(&b, InlineObject::PermEnd { id }, &props)?;
|
||||
let after_start = s.doc.insert_object(&a, InlineObject::PermStart { id, group: "everyone".into(), editor: String::new() }, &props)?;
|
||||
let mut end = b.clone();
|
||||
if end.path == a.path && end.story == a.story {
|
||||
end.off += OBJ.len_utf8();
|
||||
}
|
||||
s.sel = crate::Selection { anchor: after_start, focus: end };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the exceptions that touch the selection (or all of them).
|
||||
fn remove_exceptions(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let ids: Vec<u32> =
|
||||
s.doc.perm_ranges().iter().filter(|r| all || (r.start.story == a.story && r.start <= b && a <= r.end)).map(|r| r.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for story in stories {
|
||||
for path in s.doc.para_paths(story) {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
let marks: Vec<usize> = p
|
||||
.object_offsets()
|
||||
.into_iter()
|
||||
.filter(
|
||||
|off| matches!(p.object_at(*off), Some(InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id }) if ids.contains(id)),
|
||||
)
|
||||
.collect();
|
||||
if marks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
for off in marks.into_iter().rev() {
|
||||
para.delete(off, off + OBJ.len_utf8())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.clamp_selection();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commands always allowed in a protected document: protection itself, file commands, undo.
|
||||
fn always_allowed(id: &str) -> bool {
|
||||
id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo"
|
||||
}
|
||||
|
||||
/// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)`
|
||||
/// lets it run as long as it only changes the exceptions (checked afterwards with
|
||||
/// [`outside_unchanged`]).
|
||||
pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool, CmdError> {
|
||||
let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) };
|
||||
if always_allowed(id) {
|
||||
return Ok(false);
|
||||
}
|
||||
if s.doc.settings.protect_formatting {
|
||||
check_formatting(s, id, params)?;
|
||||
}
|
||||
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
|
||||
let exceptions = || s.doc.perm_ranges().iter().any(|r| editable_by(r, &s.author));
|
||||
match mode {
|
||||
"readOnly" if exceptions() => Ok(true),
|
||||
"readOnly" | "forms" => Err(CmdError::Disabled(format!("{id}: the document is protected (read only)"))),
|
||||
"comments" if comment_ok => Ok(false),
|
||||
"comments" if exceptions() => Ok(true),
|
||||
"comments" => Err(CmdError::Disabled(format!("{id}: only comments are allowed in this document"))),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatting limited to a selection of styles: no direct formatting, and no locked style.
|
||||
fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdError> {
|
||||
let style = match id {
|
||||
"para.style" | "format.charStyle" => p::str(params, "style"),
|
||||
"para.normal" => Some("Normal"),
|
||||
"para.heading1" => Some("Heading1"),
|
||||
"para.heading2" => Some("Heading2"),
|
||||
"para.heading3" => Some("Heading3"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(name) = style {
|
||||
let locked = s.doc.styles.styles.iter().any(|st| st.locked && (st.id == name || st.name.eq_ignore_ascii_case(name)));
|
||||
return if locked { Err(CmdError::Disabled(format!("{id}: the style {name:?} is locked in this document"))) } else { Ok(()) };
|
||||
}
|
||||
let direct = (id.starts_with("format.") && !matches!(id, "format.changeCase" | "format.state" | "format.fontDialog"))
|
||||
|| (id.starts_with("para.") && !matches!(id, "para.sort" | "para.dialog"))
|
||||
|| id.starts_with("styles.create")
|
||||
|| id.starts_with("styles.modify")
|
||||
|| id.starts_with("styles.update")
|
||||
|| id.starts_with("styles.delete");
|
||||
if direct {
|
||||
return Err(CmdError::Disabled(format!("{id}: formatting is limited to the document's styles")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Did a command leave everything outside the exceptions as it was? Compares the text, run
|
||||
/// formatting and paragraph formatting outside the ranges `author` may edit, in every story.
|
||||
pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &str) -> bool {
|
||||
outside(before, author) == outside(after, author)
|
||||
}
|
||||
|
||||
/// One piece of protected content: text with its formatting, or a paragraph mark's formatting.
|
||||
#[derive(PartialEq)]
|
||||
enum Piece {
|
||||
Text(String, CharProps),
|
||||
Object(Option<InlineObject>),
|
||||
Mark(Box<wordcraft_doc::ParaProps>, CharProps),
|
||||
}
|
||||
|
||||
/// Everything outside the editable ranges, in order.
|
||||
fn outside(doc: &Document, author: &str) -> Vec<Piece> {
|
||||
let editable: Vec<u32> = doc.perm_ranges().iter().filter(|r| editable_by(r, author)).map(|r| r.id).collect();
|
||||
let mut out = Vec::new();
|
||||
let stories = std::iter::once(StoryRef::Body).chain(doc.parts.keys().map(|k| StoryRef::Part(*k)));
|
||||
for story in stories {
|
||||
let mut open: Vec<u32> = Vec::new();
|
||||
for path in doc.para_paths(story) {
|
||||
let Some(p) = doc.para(story, &path) else { continue };
|
||||
let mut k = 0;
|
||||
for (r, props) in p.run_ranges() {
|
||||
let Some(text) = p.text.get(r.clone()) else { continue };
|
||||
let mut piece = String::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
if c == OBJ {
|
||||
let obj = p.objects.get(k);
|
||||
k += 1;
|
||||
match obj {
|
||||
Some(InlineObject::PermStart { id, .. }) if editable.contains(id) => {
|
||||
flush(&mut out, &mut piece, props, &open);
|
||||
open.push(*id);
|
||||
continue;
|
||||
}
|
||||
Some(InlineObject::PermEnd { id }) if editable.contains(id) => {
|
||||
flush(&mut out, &mut piece, props, &open);
|
||||
if let Some(j) = open.iter().rposition(|x| x == id) {
|
||||
open.remove(j);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if open.is_empty() {
|
||||
flush(&mut out, &mut piece, props, &open);
|
||||
out.push(Piece::Object(obj.cloned()));
|
||||
}
|
||||
let _ = i;
|
||||
continue;
|
||||
}
|
||||
if open.is_empty() {
|
||||
piece.push(c);
|
||||
}
|
||||
}
|
||||
flush(&mut out, &mut piece, props, &open);
|
||||
}
|
||||
if open.is_empty() {
|
||||
out.push(Piece::Mark(Box::new(p.props.clone()), p.mark.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn flush(out: &mut Vec<Piece>, piece: &mut String, props: &CharProps, open: &[u32]) {
|
||||
if !piece.is_empty() && open.is_empty() {
|
||||
out.push(Piece::Text(std::mem::take(piece), props.clone()));
|
||||
}
|
||||
piece.clear();
|
||||
}
|
||||
|
||||
/// Did undo or redo take protection away (it may only be lifted with Stop Protection)?
|
||||
pub(crate) fn protection_lifted(before: &Document, after: &Document) -> bool {
|
||||
let (b, a) = (&before.settings, &after.settings);
|
||||
b.protection.is_some() && (a.protection != b.protection || a.protect_formatting != b.protect_formatting || a.protect_hash != b.protect_hash)
|
||||
}
|
||||
|
||||
/// The first position in an editable range after `from` (wrapping round), for Find Next Region
|
||||
/// I Can Edit; with it, the range's end.
|
||||
pub fn next_region(s: &Session, from: &Pos) -> Option<(Pos, Pos)> {
|
||||
let ranges: Vec<PermRange> = s.doc.perm_ranges().into_iter().filter(|r| editable_by(r, &s.author)).collect();
|
||||
let next = ranges.iter().find(|r| r.start > *from).or_else(|| ranges.first())?;
|
||||
Some((next.start.clone(), next.end.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Selection;
|
||||
|
||||
fn select(s: &mut Session, text: &str) {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_with_an_exception_allows_edits_only_inside_it() {
|
||||
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here.\nLocked end."));
|
||||
select(&mut s, "Fill in here.");
|
||||
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
// Inside the exception: typing works.
|
||||
select(&mut s, "here");
|
||||
s.run("text.insert", &json!({"text": "there"})).unwrap();
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("Fill in there."));
|
||||
// Outside it: refused, and the document is as it was.
|
||||
let before = s.doc.plain_text(StoryRef::Body);
|
||||
select(&mut s, "intro");
|
||||
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
|
||||
assert!(s.run("format.bold", &json!({})).is_err());
|
||||
// Backspace at the start of the exception would delete outside it.
|
||||
let start = s.doc.perm_ranges()[0].start.clone();
|
||||
s.sel = Selection::caret(start);
|
||||
assert!(s.run("text.backspace", &json!({})).is_err());
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
|
||||
// Undo can't lift the protection.
|
||||
for _ in 0..5 {
|
||||
let _ = s.run("edit.undo", &json!({}));
|
||||
}
|
||||
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_guards_stop_protection_and_survives_docx() {
|
||||
let mut s = Session::new(Document::from_text("Secret plan.\nEditable part."));
|
||||
select(&mut s, "Editable part.");
|
||||
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
|
||||
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw1", "formatting": true, "lockedStyles": ["Heading1"]})).unwrap();
|
||||
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("x.docx", &bytes).unwrap();
|
||||
let st = &doc.settings;
|
||||
assert_eq!(st.protection.as_deref(), Some("readOnly"));
|
||||
assert!(st.protect_formatting);
|
||||
assert!(st.protect_hash.iter().any(|(k, v)| k == "w:algorithmName" && v == "SHA-512"));
|
||||
assert!(doc.styles.get("Heading1").is_some_and(|x| x.locked));
|
||||
let r = doc.perm_ranges();
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0].everyone());
|
||||
let mut t = Session::new(doc);
|
||||
assert!(t.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
|
||||
assert!(t.run("review.restrict", &json!({"mode": "none"})).is_err());
|
||||
assert!(t.run("para.heading1", &json!({})).is_err(), "locked style");
|
||||
t.run("review.restrict", &json!({"stop": true, "password": "pw1"})).unwrap();
|
||||
assert!(t.doc.settings.protection.is_none());
|
||||
// The password isn't kept for Repeat or macros.
|
||||
assert!(t.last_command.as_ref().is_none_or(|(_, p)| p.get("password").is_none()));
|
||||
}
|
||||
|
||||
/// A Word file with the legacy hash (`w:cryptAlgorithmSid`): a wrong password is rejected, the
|
||||
/// right one unlocks; a hash WordCraft can't check never unlocks.
|
||||
#[test]
|
||||
fn legacy_password_is_verified_and_unknown_forms_refused() {
|
||||
let protected = |sid: &str| {
|
||||
let mut doc = Document::from_text("Locked.");
|
||||
doc.settings.protection = Some("readOnly".into());
|
||||
doc.settings.protect_hash = [
|
||||
("w:cryptProviderType", "rsaFull"),
|
||||
("w:cryptAlgorithmClass", "hash"),
|
||||
("w:cryptAlgorithmType", "typeAny"),
|
||||
("w:cryptAlgorithmSid", sid),
|
||||
("w:cryptSpinCount", "100000"),
|
||||
// SHA-1 of salt 00…0F + Word's legacy key for "Example" (ECMA-376 Part 4 §14.8.1).
|
||||
("w:hash", "pEWelKoH6SVpvMmyPAJ8bHA/g/s="),
|
||||
("w:salt", "AAECAwQFBgcICQoLDA0ODw=="),
|
||||
]
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
Session::new(doc)
|
||||
};
|
||||
let mut s = protected("4");
|
||||
assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
|
||||
assert!(s.run("review.restrict", &json!({"stop": true, "password": ""})).is_err());
|
||||
assert!(super::enforced(&s.doc));
|
||||
s.run("review.restrict", &json!({"stop": true, "password": "Example"})).unwrap();
|
||||
assert!(!super::enforced(&s.doc));
|
||||
// MD2 isn't supported: refused whatever the password.
|
||||
let mut s = protected("1");
|
||||
let e = s.run("review.restrict", &json!({"stop": true, "password": "anything"})).unwrap_err();
|
||||
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
|
||||
assert!(super::enforced(&s.doc));
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,9 @@ fn autoformat(s: &mut Session, text: &str) -> Option<String> {
|
|||
return None;
|
||||
}
|
||||
let c = text.chars().next()?;
|
||||
if !s.prefs.autocorrect.enabled || !s.prefs.autocorrect.smart_quotes {
|
||||
return None;
|
||||
}
|
||||
let f = &s.sel.focus;
|
||||
let para = s.doc.para_at(f)?;
|
||||
let before = para.text.get(..f.off)?;
|
||||
|
|
@ -70,11 +73,11 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let f = s.sel.focus.clone();
|
||||
let typed = s.doc.para_at(&f).cloned().map(|p| (p, s.sel.clone()));
|
||||
// A list label ("a. ") becomes a list before AutoCorrect could capitalise it.
|
||||
let listed = text == " " && list_autoformat(s)?;
|
||||
let listed = text == " " && s.prefs.autocorrect.enabled && list_autoformat(s)?;
|
||||
if !listed {
|
||||
super::tools::autocorrect(s)?;
|
||||
}
|
||||
if text == " " && !listed {
|
||||
if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes {
|
||||
dash_autoformat(s)?;
|
||||
}
|
||||
if let Some((para, sel)) = typed
|
||||
|
|
@ -109,6 +112,10 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
|||
"I. " => ListKind::Outline,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
let ac = &s.prefs.autocorrect;
|
||||
if !(if kind.is_bullet() { ac.bullets } else { ac.numbering }) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Bullets join an existing bullet list; typing "1. " always starts numbering again at 1,
|
||||
// as in Word.
|
||||
let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None };
|
||||
|
|
@ -166,7 +173,8 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result<bool, CmdError> {
|
|||
let Some(para) = s.doc.para_at(at) else { return Ok(false) };
|
||||
let text = para.text.as_str();
|
||||
let Some(c) = text.chars().next() else { return Ok(false) };
|
||||
if !s.autocorrect_on
|
||||
if !s.prefs.autocorrect.enabled
|
||||
|| !s.prefs.autocorrect.border_lines
|
||||
|| at.off != text.len()
|
||||
|| text.len() < 3
|
||||
|| !text.chars().all(|x| x == c)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
//! Tools and the long tail: Repeat, macros, AutoCorrect, Compare, accessibility checker,
|
||||
//! document inspector, restrict editing, versions, templates, Quick Parts, and more.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, NumRef};
|
||||
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Pos, RevisionKind, StoryRef, para_block};
|
||||
|
||||
use super::{delete_selection, sel_result};
|
||||
|
|
@ -50,6 +53,240 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[
|
|||
("3/4", "¾"),
|
||||
];
|
||||
|
||||
/// Compare and Combine take the same settings.
|
||||
const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
|
||||
|
||||
/// AutoCorrect Options (Word keeps them per user; the front end saves them with its preferences).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AutoCorrectPrefs {
|
||||
/// All of AutoCorrect and AutoFormat As You Type.
|
||||
pub enabled: bool,
|
||||
/// Replace text as you type (the replacement list).
|
||||
pub replace_text: bool,
|
||||
/// Capitalize the first letter of sentences.
|
||||
pub cap_sentences: bool,
|
||||
/// Capitalize the first letter of table cells.
|
||||
pub cap_cells: bool,
|
||||
/// Capitalize names of days.
|
||||
pub cap_days: bool,
|
||||
/// AutoFormat As You Type: "straight quotes" with “smart quotes”.
|
||||
pub smart_quotes: bool,
|
||||
/// Fractions (1/2) with fraction characters (½).
|
||||
pub fractions: bool,
|
||||
/// Ordinals (1st) with superscript.
|
||||
pub ordinals: bool,
|
||||
/// Hyphens (--) with dashes.
|
||||
pub dashes: bool,
|
||||
/// Internet addresses with hyperlinks.
|
||||
pub links: bool,
|
||||
/// Automatic bulleted lists.
|
||||
pub bullets: bool,
|
||||
/// Automatic numbered lists.
|
||||
pub numbering: bool,
|
||||
/// Border lines.
|
||||
pub border_lines: bool,
|
||||
/// The user's entries: added, or replacing a built-in one.
|
||||
pub entries: Vec<(String, String)>,
|
||||
/// Built-in entries the user deleted.
|
||||
pub removed: Vec<String>,
|
||||
/// Abbreviations the user added after which a sentence doesn't end ("approx.").
|
||||
pub exceptions: Vec<String>,
|
||||
/// Built-in abbreviations the user deleted from the exceptions.
|
||||
pub exceptions_removed: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AutoCorrectPrefs {
|
||||
fn default() -> Self {
|
||||
AutoCorrectPrefs {
|
||||
enabled: true,
|
||||
replace_text: true,
|
||||
cap_sentences: true,
|
||||
cap_cells: true,
|
||||
cap_days: true,
|
||||
smart_quotes: true,
|
||||
fractions: true,
|
||||
ordinals: true,
|
||||
dashes: true,
|
||||
links: true,
|
||||
bullets: true,
|
||||
numbering: true,
|
||||
border_lines: true,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
exceptions_removed: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest AutoCorrect "replace" text, in characters.
|
||||
const MAX_FROM: usize = 255;
|
||||
/// Longest "with" text, in characters.
|
||||
const MAX_TO: usize = 2000;
|
||||
/// Most entries (and exceptions) kept.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
|
||||
/// Built-in fractions: AutoFormat's (Fractions), not the replacement list's.
|
||||
const FRACTIONS: [&str; 3] = ["1/2", "1/4", "3/4"];
|
||||
|
||||
impl AutoCorrectPrefs {
|
||||
/// Drop what a damaged or hostile saved value could hold: empty or overlong entries,
|
||||
/// duplicates, too many of them.
|
||||
pub fn cleaned(mut self) -> Self {
|
||||
let ok = |t: &str, max: usize| !t.trim().is_empty() && t.chars().count() <= max && !t.contains(['\n', '\r']);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.entries.retain(|(a, b)| ok(a, MAX_FROM) && b.chars().count() <= MAX_TO && !b.contains(['\n', '\r']) && seen.insert(a.clone()));
|
||||
self.entries.truncate(MAX_ENTRIES);
|
||||
for list in [&mut self.removed, &mut self.exceptions, &mut self.exceptions_removed] {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
list.retain(|a| ok(a, MAX_FROM) && seen.insert(a.clone()));
|
||||
list.truncate(MAX_ENTRIES);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The replacement for `word`, if any: the user's entry, else a built-in one not deleted.
|
||||
/// Built-in lowercase entries also match a capitalised word ("Teh" → "The").
|
||||
pub fn replacement(&self, word: &str) -> Option<String> {
|
||||
if let Some((_, b)) = self.entries.iter().find(|(a, _)| a == word) {
|
||||
return Some(b.clone());
|
||||
}
|
||||
AUTOCORRECT
|
||||
.iter()
|
||||
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
|
||||
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
|
||||
.find(|(a, _)| {
|
||||
*a == word
|
||||
|| (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
|
||||
})
|
||||
.map(|(a, b)| {
|
||||
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
|
||||
let mut c = b.chars();
|
||||
c.next().map(|x| x.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
|
||||
} else {
|
||||
b.to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The replacement list as the dialog shows it, sorted: `(from, to, built in)`.
|
||||
pub fn list(&self) -> Vec<(String, String, bool)> {
|
||||
let mut v: Vec<(String, String, bool)> = AUTOCORRECT
|
||||
.iter()
|
||||
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
|
||||
.map(|(a, b)| (a.to_string(), b.to_string(), true))
|
||||
.chain(self.entries.iter().map(|(a, b)| (a.clone(), b.clone(), false)))
|
||||
.collect();
|
||||
v.sort_by(|x, y| x.0.to_lowercase().cmp(&y.0.to_lowercase()).then(x.0.cmp(&y.0)));
|
||||
v
|
||||
}
|
||||
|
||||
/// Abbreviations after which a sentence doesn't end, lowercase.
|
||||
pub fn exception_list(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = NOT_SENTENCE_END
|
||||
.iter()
|
||||
.filter(|a| !self.exceptions_removed.iter().any(|r| r.eq_ignore_ascii_case(a)))
|
||||
.map(|a| a.to_string())
|
||||
.chain(self.exceptions.iter().map(|a| a.to_lowercase()))
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved AutoCorrect options: anything that doesn't read as them reads as the defaults (a
|
||||
/// damaged `ui.json` never loses the other preferences).
|
||||
pub fn lenient_autocorrect<'de, D: serde::Deserializer<'de>>(d: D) -> Result<AutoCorrectPrefs, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value::<AutoCorrectPrefs>(v).map(AutoCorrectPrefs::cleaned).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `tools.autocorrect`: change AutoCorrect Options and report them.
|
||||
fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let ac = &mut s.prefs.autocorrect;
|
||||
for (k, f) in [
|
||||
("enabled", &mut ac.enabled as &mut bool),
|
||||
("replaceText", &mut ac.replace_text),
|
||||
("capSentences", &mut ac.cap_sentences),
|
||||
("capCells", &mut ac.cap_cells),
|
||||
("capDays", &mut ac.cap_days),
|
||||
("smartQuotes", &mut ac.smart_quotes),
|
||||
("fractions", &mut ac.fractions),
|
||||
("ordinals", &mut ac.ordinals),
|
||||
("dashes", &mut ac.dashes),
|
||||
("links", &mut ac.links),
|
||||
("bullets", &mut ac.bullets),
|
||||
("numbering", &mut ac.numbering),
|
||||
("borderLines", &mut ac.border_lines),
|
||||
] {
|
||||
if let Some(b) = p::bool(v, k) {
|
||||
*f = b;
|
||||
}
|
||||
}
|
||||
let bad = |t: &str, max: usize| t.trim().is_empty() || t.chars().count() > max || t.contains(['\n', '\r']);
|
||||
if let Some(add) = v.get("add") {
|
||||
let (Some(f), Some(t)) = (add.get("from").and_then(Value::as_str), add.get("to").and_then(Value::as_str)) else {
|
||||
return Err(CmdError::Params("`add` is {\"from\": string, \"to\": string}".into()));
|
||||
};
|
||||
if bad(f, MAX_FROM) || t.chars().count() > MAX_TO || t.contains(['\n', '\r']) {
|
||||
return Err(CmdError::Params(format!("an entry replaces 1–{MAX_FROM} characters (one line) with at most {MAX_TO}")));
|
||||
}
|
||||
ac.entries.retain(|(a, _)| a != f);
|
||||
if ac.entries.len() >= MAX_ENTRIES {
|
||||
return Err(CmdError::Params("too many AutoCorrect entries".into()));
|
||||
}
|
||||
ac.entries.push((f.to_string(), t.to_string()));
|
||||
ac.removed.retain(|r| r != f);
|
||||
}
|
||||
if let Some(f) = p::str(v, "delete") {
|
||||
let n = ac.entries.len();
|
||||
ac.entries.retain(|(a, _)| a != f);
|
||||
if AUTOCORRECT.iter().any(|(a, _)| *a == f) && !ac.removed.iter().any(|r| r == f) {
|
||||
ac.removed.push(f.to_string());
|
||||
} else if n == ac.entries.len() {
|
||||
return Err(CmdError::Params(format!("no AutoCorrect entry {f:?}")));
|
||||
}
|
||||
}
|
||||
if let Some(e) = p::str(v, "addException") {
|
||||
let e = e.trim().to_lowercase();
|
||||
if bad(&e, MAX_FROM) || e.contains(char::is_whitespace) {
|
||||
return Err(CmdError::Params("an exception is one abbreviation, like \"approx.\"".into()));
|
||||
}
|
||||
ac.exceptions_removed.retain(|r| *r != e);
|
||||
if !NOT_SENTENCE_END.contains(&e.as_str()) && !ac.exceptions.contains(&e) && ac.exceptions.len() < MAX_ENTRIES {
|
||||
ac.exceptions.push(e);
|
||||
}
|
||||
}
|
||||
if let Some(e) = p::str(v, "deleteException") {
|
||||
let e = e.trim().to_lowercase();
|
||||
ac.exceptions.retain(|r| *r != e);
|
||||
if NOT_SENTENCE_END.contains(&e.as_str()) && !ac.exceptions_removed.contains(&e) {
|
||||
ac.exceptions_removed.push(e);
|
||||
}
|
||||
}
|
||||
let ac = &s.prefs.autocorrect;
|
||||
Ok(json!({
|
||||
"enabled": ac.enabled,
|
||||
"replaceText": ac.replace_text,
|
||||
"capSentences": ac.cap_sentences,
|
||||
"capCells": ac.cap_cells,
|
||||
"capDays": ac.cap_days,
|
||||
"smartQuotes": ac.smart_quotes,
|
||||
"fractions": ac.fractions,
|
||||
"ordinals": ac.ordinals,
|
||||
"dashes": ac.dashes,
|
||||
"links": ac.links,
|
||||
"bullets": ac.bullets,
|
||||
"numbering": ac.numbering,
|
||||
"borderLines": ac.border_lines,
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
"user": ac.entries,
|
||||
"exceptions": ac.exception_list(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.repeat", "Repeat", "Quick Access Toolbar", |s, _| {
|
||||
|
|
@ -75,20 +312,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("tools.macros", "Macros", "View › Macros", macros)
|
||||
.params(r#"{"run"?: name, "define"?: {"name": string, "steps": [{"command", "params"}]}, "delete"?: name}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", |s, v| {
|
||||
if let Some(b) = p::bool(v, "enabled") {
|
||||
s.autocorrect_on = b;
|
||||
}
|
||||
if let (Some(f), Some(t)) =
|
||||
(v.get("add").and_then(|a| a.get("from")).and_then(Value::as_str), v.get("add").and_then(|a| a.get("to")).and_then(Value::as_str))
|
||||
{
|
||||
s.autocorrect_user.retain(|(a, _)| a != f);
|
||||
s.autocorrect_user.push((f.to_string(), t.to_string()));
|
||||
}
|
||||
Ok(json!({"enabled": s.autocorrect_on, "entries": AUTOCORRECT.len() + s.autocorrect_user.len(), "user": s.autocorrect_user}))
|
||||
})
|
||||
.params(r#"{"enabled"?: bool, "add"?: {"from": string, "to": string}}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"capSentences"|"capCells"|"capDays"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
.params(
|
||||
r#"{"command"?: id (its keys), "key"?: "Mod+Shift+K" (the command it runs), "assign"?: {"command": id, "key": string}, "remove"?: {"command": id, "key": string}, "reset"?: true (built-in keys only)} → {custom: {assigned: {key: id}, removed: [key]}, …}; no params opens the dialog"#,
|
||||
|
|
@ -99,20 +327,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
r#"{"hide"?: tab, "show"?: tab, "newTab"?: {"name"?, "after"?: tab}, "newGroup"?: {"tab", "name"?}, "add"?: {"tab", "group", "command", "after"?: id}, "remove"?: {"tab", "group"?, "command"?}, "rename"?: {"tab", "group"?, "to"}, "move"?: {"tab", "group"?, "command"?, "by": int}, "qatAdd"?: id, "qatRemove"?: id, "qatMove"?: {"command", "by": int}, "reset"?: true | "ribbon" | "qat", "layout"?: object} → {tabs: [{name, custom?, hidden?, groups?: [{name, commands}]}], qat: [id]}; only custom tabs and groups can be renamed or removed (built-in tabs can be hidden and moved); no params opens the dialog"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(r#"{"path"?: string, "text"?: string (revised version)}"#),
|
||||
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(r#"{"path"?: string}"#),
|
||||
CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(COMPARE_PARAMS),
|
||||
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS),
|
||||
CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(),
|
||||
CommandSpec::new("file.inspect", "Inspect Document", "File › Info", inspect_doc)
|
||||
.params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]}"#),
|
||||
CommandSpec::new("review.restrict", "Restrict Editing", "Review › Protect", |s, v| {
|
||||
let mode = p::str(v, "mode").unwrap_or("readOnly").to_string();
|
||||
s.doc.settings.protection = if mode == "none" { None } else { Some(mode.clone()) };
|
||||
if mode == "trackedChanges" {
|
||||
s.doc.settings.track_changes = true;
|
||||
}
|
||||
Ok(json!({"protection": s.doc.settings.protection}))
|
||||
})
|
||||
.params(r#"{"mode": "none|readOnly|comments|trackedChanges|forms"}"#),
|
||||
CommandSpec::new("file.protect", "Protect Document", "File › Info", |s, v| {
|
||||
let mode = p::str(v, "mode").unwrap_or("readOnly");
|
||||
s.run("review.restrict", &json!({"mode": mode}))
|
||||
|
|
@ -483,9 +702,10 @@ pub fn autocorrect(s: &mut Session) -> Result<(), CmdError> {
|
|||
/// AutoCorrect the word ending at the caret; `on_enter` means Enter finished it (no trigger
|
||||
/// character was typed).
|
||||
pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> {
|
||||
if !s.autocorrect_on {
|
||||
if !s.prefs.autocorrect.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let ac = s.prefs.autocorrect.clone();
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) };
|
||||
let Some(before) = text.get(..f.off) else { return Ok(()) };
|
||||
|
|
@ -500,24 +720,11 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
|||
if word.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let user = s.autocorrect_user.iter().find(|(a, _)| *a == word).map(|(_, b)| b.clone());
|
||||
let builtin = AUTOCORRECT
|
||||
.iter()
|
||||
.find(|(a, _)| {
|
||||
*a == word || (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
|
||||
})
|
||||
.map(|(a, b)| {
|
||||
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
|
||||
let mut c = b.chars();
|
||||
c.next().map(|x| x.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
|
||||
} else {
|
||||
b.to_string()
|
||||
}
|
||||
});
|
||||
// A whole-word replacement, else "wait..." → "wait…".
|
||||
let (from, rep) = match user.or(builtin) {
|
||||
let found = if ac.replace_text { ac.replacement(&word) } else { None };
|
||||
let (from, rep) = match found {
|
||||
Some(r) => (start, Some(r)),
|
||||
None if word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())),
|
||||
None if ac.replace_text && word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())),
|
||||
None => (start, None),
|
||||
};
|
||||
let mut caret = f.off;
|
||||
|
|
@ -534,13 +741,13 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
|||
let Some(word) = s.doc.para_at(&f).and_then(|p| p.text.get(start..end)).map(str::to_string) else { return Ok(()) };
|
||||
let at = |off: usize| Pos { off, ..f.clone() };
|
||||
// 1st, 22nd, 103rd → superscript suffix.
|
||||
if let Some(n) = ordinal_suffix(&word) {
|
||||
if let Some(n) = ordinal_suffix(&word).filter(|_| ac.ordinals) {
|
||||
s.doc.format_range(&at(end - n), &at(end), &|c| c.vert_align = Some(wordcraft_doc::props::VertAlign::Superscript))?;
|
||||
return Ok(());
|
||||
}
|
||||
// Web addresses become links (trailing punctuation stays outside).
|
||||
let lower = word.to_ascii_lowercase();
|
||||
if (lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("www.")) && word.len() > 8 {
|
||||
if ac.links && (lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("www.")) && word.len() > 8 {
|
||||
let url = word.trim_end_matches(|c: char| ".,;:!?)]\"'”’".contains(c));
|
||||
let href = if lower.starts_with("www.") { format!("http://{url}") } else { url.to_string() };
|
||||
let url_end = start + url.len();
|
||||
|
|
@ -555,7 +762,22 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
|||
s.pending = Some(after);
|
||||
return Ok(());
|
||||
}
|
||||
if starts_sentence(text.get(..start).unwrap_or("")) && should_capitalize(&word, if on_enter { '\n' } else { last }) {
|
||||
let before_word = text.get(..start).unwrap_or("");
|
||||
// The first word in a table cell follows its own option; other sentences the sentence one.
|
||||
let sentence_on = if before_word.trim().is_empty() && f.path.cell().is_some() { ac.cap_cells } else { ac.cap_sentences };
|
||||
let exceptions = ac.exception_list();
|
||||
// A replacement of several words ("brb" → "be right back") is capitalised by its first.
|
||||
let first = word.split_whitespace().next().unwrap_or("");
|
||||
let next = if first.len() < word.len() {
|
||||
' '
|
||||
} else if on_enter {
|
||||
'\n'
|
||||
} else {
|
||||
last
|
||||
};
|
||||
let sentence = sentence_on && starts_sentence(before_word, &exceptions) && should_capitalize(first, next);
|
||||
let day = ac.cap_days && DAYS.contains(&first);
|
||||
if sentence || day {
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
if let Some(c) = word.chars().next() {
|
||||
let up: String = c.to_uppercase().collect();
|
||||
|
|
@ -593,8 +815,12 @@ const NOT_SENTENCE_END: &[&str] = &[
|
|||
"ltd.", "co.", "vol.", "a.m.", "p.m.",
|
||||
];
|
||||
|
||||
/// Names of days, capitalised as you type (Capitalize names of days).
|
||||
const DAYS: [&str; 7] = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
||||
|
||||
/// Does a word typed after `before` (the paragraph text up to the word) begin a sentence?
|
||||
fn starts_sentence(before: &str) -> bool {
|
||||
/// `exceptions` are the lowercase abbreviations whose full stop doesn't end one.
|
||||
fn starts_sentence(before: &str, exceptions: &[String]) -> bool {
|
||||
let t = before.trim_end();
|
||||
if t.is_empty() {
|
||||
return true;
|
||||
|
|
@ -610,7 +836,7 @@ fn starts_sentence(before: &str) -> bool {
|
|||
let lower = prev.to_lowercase();
|
||||
// "J." (an initial) or a known abbreviation.
|
||||
let initial = prev.chars().count() == 2 && prev.chars().next().is_some_and(char::is_uppercase);
|
||||
!initial && !NOT_SENTENCE_END.contains(&lower.as_str())
|
||||
!initial && !exceptions.contains(&lower)
|
||||
}
|
||||
|
||||
/// Only plain lowercase words are capitalised (not "iPhone", "x2", file names or addresses).
|
||||
|
|
@ -628,8 +854,34 @@ fn should_capitalize(word: &str, next: char) -> bool {
|
|||
&& !word.trim_end_matches('.').contains('.')
|
||||
}
|
||||
|
||||
/// Compare the current document (original) with a revised one: the result shows the
|
||||
/// differences as tracked insertions and deletions.
|
||||
/// What Compare / Combine compares (the More >> settings of Word's dialog).
|
||||
struct CompareOptions {
|
||||
/// Differences only in letter case count as changes.
|
||||
case: bool,
|
||||
/// Differences only in white space count as changes.
|
||||
white_space: bool,
|
||||
/// Formatting differences become tracked formatting changes.
|
||||
formatting: bool,
|
||||
/// Changed paragraphs are compared character by character (else word by word).
|
||||
characters: bool,
|
||||
}
|
||||
|
||||
impl CompareOptions {
|
||||
/// The text a comparison sees: case folded and white space collapsed as the options say.
|
||||
fn key(&self, t: &str) -> String {
|
||||
let t = if self.white_space { t.to_string() } else { t.split_whitespace().collect::<Vec<_>>().join(" ") };
|
||||
if self.case { t } else { t.to_lowercase() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Changed paragraphs longer than this (in characters) are compared word by word even at
|
||||
/// character level, so the comparison table stays small.
|
||||
const MAX_CHAR_DIFF: usize = 2000;
|
||||
|
||||
/// Compare the current document (original, or `original` from a file) with a revised one: the
|
||||
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
|
||||
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
|
||||
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
|
||||
fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let revised = if let Some(t) = p::str(v, "text") {
|
||||
Document::from_text(t)
|
||||
|
|
@ -638,63 +890,132 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
|||
} else {
|
||||
return Err(CmdError::Params("`path` or `text` of the revised document is required".into()));
|
||||
};
|
||||
let old: Vec<Paragraph> = s.doc.body.iter().filter_map(|b| b.as_para().cloned()).collect();
|
||||
let new: Vec<Paragraph> = revised.body.iter().filter_map(|b| b.as_para().cloned()).collect();
|
||||
let author = "Compare".to_string();
|
||||
let show_in = p::str(v, "showIn").unwrap_or("original");
|
||||
if !matches!(show_in, "original" | "revised" | "new") {
|
||||
return Err(CmdError::Params("`showIn` is \"original\", \"revised\" or \"new\"".into()));
|
||||
}
|
||||
if let Some(path) = p::str(v, "original") {
|
||||
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
s.doc = doc;
|
||||
s.doc.ensure_nonempty();
|
||||
}
|
||||
let level = p::str(v, "level").unwrap_or("word");
|
||||
let opts = CompareOptions {
|
||||
case: p::bool(v, "caseChanges").unwrap_or(true),
|
||||
white_space: p::bool(v, "whiteSpace").unwrap_or(true),
|
||||
formatting: p::bool(v, "formatting").unwrap_or(true),
|
||||
characters: matches!(level, "character" | "char"),
|
||||
};
|
||||
let not_honoured: Vec<&str> =
|
||||
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
|
||||
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
|
||||
let date = super::now_iso();
|
||||
let old: Vec<Arc<Block>> = s.doc.body.clone();
|
||||
let new: Vec<Arc<Block>> = revised.body.clone();
|
||||
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone(), move_name: None });
|
||||
let ins = (s.doc.revisions.len() - 1) as u32;
|
||||
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author, date, move_name: None });
|
||||
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author: author.clone(), date: date.clone(), move_name: None });
|
||||
let del = (s.doc.revisions.len() - 1) as u32;
|
||||
let ops = lcs_ops(&old.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), &new.iter().map(|p| p.plain_text()).collect::<Vec<_>>());
|
||||
let mut out: Vec<Paragraph> = Vec::new();
|
||||
let key = |b: &Arc<Block>| match &**b {
|
||||
Block::Para(p) => opts.key(&p.plain_text()),
|
||||
// Tables match as a whole, by their text.
|
||||
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
|
||||
};
|
||||
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>());
|
||||
let mut out: Vec<Arc<Block>> = Vec::new();
|
||||
let (mut i, mut j) = (0usize, 0usize);
|
||||
let mut changes = 0usize;
|
||||
let mut k = 0;
|
||||
while k < ops.len() {
|
||||
match ops.get(k) {
|
||||
Some(Op::Same) => {
|
||||
if let Some(p) = old.get(i) {
|
||||
out.push(p.clone());
|
||||
let (a, b) = (old.get(i), new.get(j));
|
||||
// The revised block, with its formatting changes tracked.
|
||||
if let (Some(Block::Para(pa)), Some(Block::Para(pb))) = (a.map(|x| &**x), b.map(|x| &**x)) {
|
||||
let mut p = pb.clone();
|
||||
if opts.formatting && pa.text == pb.text {
|
||||
let before = s.doc.revisions.len();
|
||||
super::fmt_revisions::compare_formatting(pa, &mut p, &mut s.doc.revisions, &author, &date);
|
||||
if s.doc.revisions.len() > before || p != *pb {
|
||||
changes += 1;
|
||||
}
|
||||
}
|
||||
out.push(para_block(p));
|
||||
} else if let Some(x) = b.or(a) {
|
||||
out.push(x.clone());
|
||||
}
|
||||
i += 1;
|
||||
j += 1;
|
||||
k += 1;
|
||||
}
|
||||
Some(Op::Del) if matches!(ops.get(k + 1), Some(Op::Ins)) => {
|
||||
// A changed paragraph: diff its words.
|
||||
let (a, b) = (old.get(i).cloned().unwrap_or_default(), new.get(j).cloned().unwrap_or_default());
|
||||
out.push(word_diff(&a, &b, ins, del));
|
||||
changes += 1;
|
||||
i += 1;
|
||||
j += 1;
|
||||
k += 2;
|
||||
}
|
||||
Some(Op::Del) => {
|
||||
let mut p = old.get(i).cloned().unwrap_or_default();
|
||||
let len = p.len();
|
||||
let _ = p.format(0, len, &|c| c.del = Some(del));
|
||||
out.push(p);
|
||||
changes += 1;
|
||||
i += 1;
|
||||
k += 1;
|
||||
}
|
||||
Some(Op::Ins) => {
|
||||
let mut p = new.get(j).cloned().unwrap_or_default();
|
||||
let len = p.len();
|
||||
let _ = p.format(0, len, &|c| c.ins = Some(ins));
|
||||
out.push(p);
|
||||
changes += 1;
|
||||
j += 1;
|
||||
k += 1;
|
||||
Some(_) => {
|
||||
// A run of deletions and insertions: changed paragraphs pair up and are diffed
|
||||
// word by word (or by character); the rest are deleted or inserted whole.
|
||||
let run = ops.get(k..).unwrap_or_default().iter().take_while(|o| **o != Op::Same).count();
|
||||
let dels = ops.get(k..k + run).unwrap_or_default().iter().filter(|o| **o == Op::Del).count();
|
||||
let inss = run - dels;
|
||||
for x in 0..dels.max(inss) {
|
||||
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
|
||||
match (a.map(|x| &**x), b.map(|x| &**x)) {
|
||||
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts))),
|
||||
_ => {
|
||||
if let Some(x) = a {
|
||||
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
|
||||
}
|
||||
if let Some(x) = b {
|
||||
out.push(Arc::new(mark_block(x, &|c| c.ins = Some(ins), 0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
changes += 1;
|
||||
}
|
||||
i += dels;
|
||||
j += inss;
|
||||
k += run.max(1);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
s.doc.body = out.into_iter().map(para_block).collect();
|
||||
s.doc.body = out;
|
||||
s.doc.ensure_nonempty();
|
||||
if show_in != "original" || p::str(v, "original").is_some() {
|
||||
// The result is a new, untitled document: saving it never overwrites either file.
|
||||
s.path = None;
|
||||
}
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
Ok(json!({"changes": changes}))
|
||||
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
|
||||
}
|
||||
|
||||
/// A block's text (a table's cells in order), for matching blocks.
|
||||
fn block_text(b: &Block, depth: usize) -> String {
|
||||
match b {
|
||||
Block::Para(p) => p.plain_text(),
|
||||
Block::Table(t) if depth < 16 => {
|
||||
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|x| block_text(x, depth + 1)).collect::<Vec<_>>().join("\t")
|
||||
}
|
||||
Block::Table(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
|
||||
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
|
||||
match b {
|
||||
Block::Para(p) => {
|
||||
let mut p = p.clone();
|
||||
let len = p.len();
|
||||
let _ = p.format(0, len, f);
|
||||
Block::Para(p)
|
||||
}
|
||||
Block::Table(t) => {
|
||||
let mut t = t.clone();
|
||||
if depth < 16 {
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
c.blocks = c.blocks.iter().map(|x| Arc::new(mark_block(x, f, depth + 1))).collect();
|
||||
}
|
||||
}
|
||||
Block::Table(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
|
@ -704,9 +1025,19 @@ enum Op {
|
|||
Ins,
|
||||
}
|
||||
|
||||
/// LCS edit script between two sequences (bounded size).
|
||||
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
|
||||
/// compared as deleted and inserted, never dropped.
|
||||
const MAX_LCS: usize = 4000;
|
||||
|
||||
/// LCS edit script between two sequences: the common start and end are matched first, then the
|
||||
/// middle (bounded size).
|
||||
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
|
||||
let (n, m) = (a.len().min(4000), b.len().min(4000));
|
||||
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
|
||||
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
|
||||
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
|
||||
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
|
||||
let mut ops = vec![Op::Same; pre];
|
||||
let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
|
||||
let mut t = vec![vec![0u32; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
|
|
@ -721,7 +1052,6 @@ fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
|
|||
}
|
||||
}
|
||||
let (mut i, mut j) = (0, 0);
|
||||
let mut ops = Vec::new();
|
||||
while i < n && j < m {
|
||||
if a.get(i) == b.get(j) {
|
||||
ops.push(Op::Same);
|
||||
|
|
@ -735,12 +1065,17 @@ fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
|
|||
j += 1;
|
||||
}
|
||||
}
|
||||
ops.extend(std::iter::repeat_n(Op::Del, n - i));
|
||||
ops.extend(std::iter::repeat_n(Op::Ins, m - j));
|
||||
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
|
||||
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
|
||||
ops.extend(std::iter::repeat_n(Op::Same, suf));
|
||||
ops
|
||||
}
|
||||
|
||||
fn tokens(s: &str) -> Vec<String> {
|
||||
/// Words with the spaces after them ("The ", "cat "), or single characters.
|
||||
fn tokens(s: &str, characters: bool) -> Vec<String> {
|
||||
if characters {
|
||||
return s.chars().map(String::from).collect();
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for c in s.chars() {
|
||||
|
|
@ -755,9 +1090,11 @@ fn tokens(s: &str) -> Vec<String> {
|
|||
v
|
||||
}
|
||||
|
||||
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32) -> Paragraph {
|
||||
let (ta, tb) = (tokens(&a.plain_text()), tokens(&b.plain_text()));
|
||||
let ops = lcs_ops(&ta, &tb);
|
||||
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph {
|
||||
let (at, bt) = (a.plain_text(), b.plain_text());
|
||||
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
|
||||
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
|
||||
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
|
||||
let mut p = Paragraph::new();
|
||||
p.props = b.props.clone();
|
||||
let base = a.runs.first().map(|r| r.props.clone()).unwrap_or_default();
|
||||
|
|
@ -766,7 +1103,8 @@ fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32) -> Paragraph {
|
|||
let at = p.len();
|
||||
match op {
|
||||
Op::Same => {
|
||||
let _ = p.insert_text(at, ta.get(i).map(String::as_str).unwrap_or(""), &base);
|
||||
// Equal as compared (perhaps up to case or spacing): the revised text.
|
||||
let _ = p.insert_text(at, tb.get(j).map(String::as_str).unwrap_or(""), &base);
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
|
|
@ -1040,6 +1378,38 @@ mod tests {
|
|||
assert_eq!(s.doc.plain_text(StoryRef::Body), "The dog sat.\nKeep this.\nNew line here.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_options_ignore_case_and_white_space() {
|
||||
let original = "The Cat sat.\nTwo spaces here.";
|
||||
let revised = "The cat sat.\nTwo spaces here.";
|
||||
// By default both differences are changes.
|
||||
let mut s = Session::new(Document::from_text(original));
|
||||
let r = s.run("review.compare", &json!({"text": revised, "author": "Reviewer"})).unwrap();
|
||||
assert_eq!(r["changes"], 2);
|
||||
assert!(s.doc.revisions.iter().any(|x| x.author == "Reviewer"));
|
||||
// Ignoring case and white space: no changes, and the result reads as the revised text.
|
||||
let mut s = Session::new(Document::from_text(original));
|
||||
let r = s.run("review.compare", &json!({"text": revised, "caseChanges": false, "whiteSpace": false})).unwrap();
|
||||
assert_eq!(r["changes"], 0);
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
|
||||
// Character level marks only the changed letters.
|
||||
let mut s = Session::new(Document::from_text("colour"));
|
||||
s.run("review.compare", &json!({"text": "color", "level": "character"})).unwrap();
|
||||
let p = s.doc.body[0].as_para().unwrap();
|
||||
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
|
||||
assert_eq!(deleted, "u");
|
||||
// Formatting differences become formatting changes.
|
||||
let mut s = Session::new(Document::from_text("Same words."));
|
||||
let mut rev = Document::from_text("Same words.");
|
||||
rev.format_range(&rev.start_of(StoryRef::Body), &rev.end_of(StoryRef::Body), &|c| c.bold = Some(true)).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("wc-compare-{}.docx", std::process::id()));
|
||||
crate::io::save_path(&path, &rev).unwrap();
|
||||
let r = s.run("review.compare", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(r["changes"], 1);
|
||||
assert!(s.doc.revisions.iter().any(|x| x.kind == RevisionKind::Format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_record_and_run() {
|
||||
let mut s = Session::new(Document::new());
|
||||
|
|
|
|||
|
|
@ -250,8 +250,6 @@ pub struct Session {
|
|||
/// Macro being recorded: (name, steps).
|
||||
pub recording: Option<(String, Vec<(String, Value)>)>,
|
||||
pub macros: std::collections::BTreeMap<String, Vec<(String, Value)>>,
|
||||
pub autocorrect_on: bool,
|
||||
pub autocorrect_user: Vec<(String, String)>,
|
||||
/// Saved versions: (label, date, document).
|
||||
pub versions: Vec<(String, String, Document)>,
|
||||
/// Quick Parts / AutoText entries.
|
||||
|
|
@ -318,11 +316,15 @@ pub struct Prefs {
|
|||
pub markup: wordcraft_layout::display::MarkupOptions,
|
||||
/// Language › Detect language automatically (per user, as in Word).
|
||||
pub detect_language: bool,
|
||||
/// AutoCorrect Options: replacements, capitalization and AutoFormat As You Type (per user, as
|
||||
/// in Word). A damaged saved value reads as the defaults.
|
||||
#[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")]
|
||||
pub autocorrect: crate::cmd::tools::AutoCorrectPrefs,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Prefs { count_notes: true, markup: Default::default(), detect_language: true }
|
||||
Prefs { count_notes: true, markup: Default::default(), detect_language: true, autocorrect: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -363,8 +365,6 @@ impl Session {
|
|||
last_command: None,
|
||||
recording: None,
|
||||
macros: Default::default(),
|
||||
autocorrect_on: true,
|
||||
autocorrect_user: Vec::new(),
|
||||
versions: Vec::new(),
|
||||
building_blocks: Default::default(),
|
||||
autosave: true,
|
||||
|
|
@ -558,6 +558,11 @@ impl Session {
|
|||
self.touch();
|
||||
true
|
||||
}
|
||||
/// Would Undo (or Redo) take the document's protection away? Only Stop Protection lifts it.
|
||||
pub fn step_lifts_protection(&self, redo: bool) -> bool {
|
||||
let next = if redo { self.redo.last() } else { self.history.last() };
|
||||
next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc))
|
||||
}
|
||||
pub fn redo(&mut self) -> bool {
|
||||
self.typing_open = false;
|
||||
let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false };
|
||||
|
|
@ -640,8 +645,6 @@ impl Session {
|
|||
last_command: _,
|
||||
recording: _,
|
||||
macros: _,
|
||||
autocorrect_on: _,
|
||||
autocorrect_user: _,
|
||||
versions: _,
|
||||
building_blocks: _,
|
||||
autosave: _,
|
||||
|
|
@ -734,27 +737,27 @@ impl Session {
|
|||
if let Some(why) = (spec.enabled)(self) {
|
||||
return Err(CmdError::Disabled(format!("{id}: {why}")));
|
||||
}
|
||||
// Restrict Editing.
|
||||
if spec.mutates
|
||||
&& let Some(mode) = self.doc.settings.protection.clone()
|
||||
{
|
||||
let allowed = id.starts_with("review.restrict") || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo";
|
||||
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
|
||||
match mode.as_str() {
|
||||
"readOnly" | "forms" if !allowed => return Err(CmdError::Disabled(format!("{id}: the document is protected (read only)"))),
|
||||
"comments" if !allowed && !comment_ok => return Err(CmdError::Disabled(format!("{id}: only comments are allowed in this document"))),
|
||||
"trackedChanges" => self.doc.settings.track_changes = true,
|
||||
_ => {}
|
||||
}
|
||||
// Restrict Editing: refused outright, or allowed as long as it only edits the exceptions.
|
||||
let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false };
|
||||
if spec.mutates && self.doc.settings.protection.as_deref() == Some("trackedChanges") {
|
||||
self.doc.settings.track_changes = true;
|
||||
}
|
||||
// Macro recording and Repeat.
|
||||
let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat")
|
||||
&& (spec.mutates || id.starts_with("caret.") || id.starts_with("select."));
|
||||
// A password never lands in a recorded macro or Repeat.
|
||||
let kept = || {
|
||||
let mut p = params.clone();
|
||||
if let Some(m) = p.as_object_mut() {
|
||||
m.remove("password");
|
||||
}
|
||||
p
|
||||
};
|
||||
if record && let Some((_, steps)) = self.recording.as_mut() {
|
||||
steps.push((id.to_string(), params.clone()));
|
||||
steps.push((id.to_string(), kept()));
|
||||
}
|
||||
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
self.last_command = Some((id.to_string(), kept()));
|
||||
}
|
||||
// Typing, caret movement and selection outside the equation commands leave the equation.
|
||||
if self.math.is_some()
|
||||
|
|
@ -801,13 +804,21 @@ impl Session {
|
|||
let run = spec.run;
|
||||
let mutates = spec.mutates;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let result = match result {
|
||||
let mut result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
|
||||
}
|
||||
};
|
||||
// Restrict Editing: outside the exceptions nothing may change.
|
||||
if result.is_ok()
|
||||
&& guard
|
||||
&& let Some((before, ..)) = &before_doc
|
||||
&& !crate::cmd::protect::outside_unchanged(before, &self.doc, &self.author)
|
||||
{
|
||||
result = Err(CmdError::Disabled(format!("{id}: only the editable regions of this protected document can change")));
|
||||
}
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
if spec.mutates {
|
||||
|
|
|
|||
|
|
@ -312,8 +312,9 @@ fn failed_command_restores_history_changed_by_nested_commands() {
|
|||
// outer and nested checkpoints each evict the oldest step.
|
||||
fn timeline(s: &mut Session) -> Vec<(Vec<String>, wordcraft_doc::Document)> {
|
||||
let mut out = Vec::new();
|
||||
// Straight through the history (the commands won't undo past Restrict Editing).
|
||||
while s.can_redo() {
|
||||
run(s, "edit.redo", json!({}));
|
||||
s.redo();
|
||||
}
|
||||
loop {
|
||||
// Comments are stamped with the time to the second, and the two sessions compared
|
||||
|
|
@ -324,7 +325,7 @@ fn failed_command_restores_history_changed_by_nested_commands() {
|
|||
if !s.can_undo() {
|
||||
return out;
|
||||
}
|
||||
run(s, "edit.undo", json!({}));
|
||||
s.undo();
|
||||
}
|
||||
}
|
||||
let kept = {
|
||||
|
|
@ -2083,7 +2084,7 @@ fn column_selection_copies_the_block() {
|
|||
// A caret move drops the block; pasting the copy gives three paragraphs.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
assert!(s.column_segments().is_none());
|
||||
s.autocorrect_on = false;
|
||||
s.prefs.autocorrect.enabled = false;
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef\ncd\ncd\ncd");
|
||||
|
|
|
|||
|
|
@ -185,6 +185,23 @@ fn autocorrect_as_you_type() {
|
|||
assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_options_change_what_typing_does() {
|
||||
let mut s = s();
|
||||
// A user entry, a deleted built-in entry, an added exception; names of days.
|
||||
run(&mut s, "tools.autocorrect", json!({"add": {"from": "wc", "to": "WordCraft"}}));
|
||||
run(&mut s, "tools.autocorrect", json!({"delete": "teh"}));
|
||||
run(&mut s, "tools.autocorrect", json!({"addException": "approx."}));
|
||||
typ(&mut s, "wc ships on monday. teh end is approx. three weeks ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "WordCraft ships on Monday. Teh end is approx. three weeks ");
|
||||
// AutoFormat options off: straight quotes, no dashes, no list; sentences not capitalised.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"smartQuotes": false, "dashes": false, "bullets": false, "capSentences": false, "capDays": false}));
|
||||
typ(&mut s, "* \"hi\" a -- b sunday ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "* \"hi\" a -- b sunday ");
|
||||
assert_eq!(paras(&s)[0].1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_finishes_a_word_for_autocorrect() {
|
||||
let s = typed("hello\nteh end\n");
|
||||
|
|
|
|||
|
|
@ -295,6 +295,8 @@ pub enum Dialog {
|
|||
TrackOptions {
|
||||
form: Box<crate::dialogs_lists::TrackForm>,
|
||||
},
|
||||
/// Compare / Combine Documents and AutoCorrect Options ([`crate::dialogs_review`], #412).
|
||||
Review(Box<crate::dialogs_review::ReviewDialog>),
|
||||
/// Tools › Customize Keyboard (#368).
|
||||
CustomizeKeyboard {
|
||||
form: Box<crate::dialogs_keyboard::KeyboardForm>,
|
||||
|
|
@ -737,6 +739,7 @@ impl Dialog {
|
|||
Dialog::EncryptPassword { .. } => "encryptPassword",
|
||||
Dialog::DefineList { .. } => "defineList",
|
||||
Dialog::TrackOptions { .. } => "trackChangesOptions",
|
||||
Dialog::Review(d) => d.name(),
|
||||
Dialog::CustomizeKeyboard { .. } => "customizeKeyboard",
|
||||
Dialog::CustomizeRibbon { .. } => "customizeRibbon",
|
||||
Dialog::Control { .. } => "controlProperties",
|
||||
|
|
@ -772,6 +775,9 @@ impl Dialog {
|
|||
if let Some(d) = crate::dialogs_layout::open(name, app) {
|
||||
return Some(Dialog::Layout(Box::new(d)));
|
||||
}
|
||||
if matches!(name, "compare" | "combine" | "autoCorrect" | "restrictEditing") {
|
||||
return crate::dialogs_review::open(name, app).map(|d| Dialog::Review(Box::new(d)));
|
||||
}
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let s = |k: &str| st.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
|
|
@ -1129,6 +1135,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
|||
Dialog::EncryptPassword { .. } => "Encrypt with Password",
|
||||
Dialog::DefineList { .. } => "Define New Multilevel List",
|
||||
Dialog::TrackOptions { .. } => "Track Changes Options",
|
||||
Dialog::Review(d) => d.title(),
|
||||
Dialog::CustomizeKeyboard { .. } => "Customize Keyboard",
|
||||
Dialog::CustomizeRibbon { .. } => "Customize Ribbon",
|
||||
Dialog::Control { .. } => "Content Control Properties",
|
||||
|
|
@ -2091,6 +2098,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
Dialog::DefineList { form } => crate::dialogs_lists::define_list(app, ui, form),
|
||||
Dialog::TrackOptions { form } => crate::dialogs_lists::track_options(app, ui, form),
|
||||
Dialog::Review(d) => crate::dialogs_review::body(app, ui, d),
|
||||
Dialog::CustomizeKeyboard { form } => crate::dialogs_keyboard::customize_keyboard(app, ui, form),
|
||||
Dialog::CustomizeRibbon { form } => crate::dialogs_ribbon::customize_ribbon(app, ui, form),
|
||||
Dialog::FieldCode { code, edit, message } => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,829 @@
|
|||
//! Review dialogs and the Restrict Editing pane (#412): Compare / Combine Documents (with the
|
||||
//! More >> comparison settings), AutoCorrect Options (AutoCorrect, AutoFormat As You Type and
|
||||
//! Math AutoCorrect) and the Restrict Editing side pane. Each ends by running a command
|
||||
//! (`review.compare`, `review.combine`, `tools.autocorrect`, `review.restrict`), so scripts and
|
||||
//! agents get the same result without them. Shown through [`crate::dialogs::Dialog::Review`] and
|
||||
//! [`crate::panes`].
|
||||
|
||||
use egui::{Color32, Ui, vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::Password;
|
||||
use wordcraft_engine::cmd::tools::AutoCorrectPrefs;
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::semibold;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "form", rename_all = "camelCase")]
|
||||
pub enum ReviewDialog {
|
||||
Compare(CompareForm),
|
||||
AutoCorrect(Box<AutoCorrectForm>),
|
||||
}
|
||||
|
||||
impl ReviewDialog {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ReviewDialog::Compare(f) if f.combine => "combine",
|
||||
ReviewDialog::Compare(_) => "compare",
|
||||
ReviewDialog::AutoCorrect(_) => "autoCorrect",
|
||||
}
|
||||
}
|
||||
|
||||
/// The window title (English; the caller translates it).
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
ReviewDialog::Compare(f) if f.combine => "Combine Documents",
|
||||
ReviewDialog::Compare(_) => "Compare Documents",
|
||||
ReviewDialog::AutoCorrect(_) => "AutoCorrect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog `ui.dialog` opens by `name`: `compare`, `combine` or `autoCorrect`.
|
||||
/// `restrictEditing` opens the Restrict Editing pane instead (no dialog).
|
||||
pub fn open(name: &str, app: &mut WordApp) -> Option<ReviewDialog> {
|
||||
Some(match name {
|
||||
"compare" | "combine" => ReviewDialog::Compare(CompareForm::new(app, name == "combine")),
|
||||
"autoCorrect" => ReviewDialog::AutoCorrect(Box::new(AutoCorrectForm::new(app))),
|
||||
"restrictEditing" => {
|
||||
app.restrict.open = true;
|
||||
return None;
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Draws the dialog; returns true to close it.
|
||||
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut ReviewDialog) -> bool {
|
||||
match d {
|
||||
ReviewDialog::Compare(f) => compare_ui(app, ui, f),
|
||||
ReviewDialog::AutoCorrect(f) => autocorrect_ui(app, ui, f),
|
||||
}
|
||||
}
|
||||
|
||||
/// OK / Cancel; returns (ok, cancel). `ok_enabled` greys OK out.
|
||||
fn buttons(ui: &mut Ui, ok_enabled: bool) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
r.1 = true;
|
||||
}
|
||||
let okb =
|
||||
ui.add_enabled(ok_enabled, egui::Button::new(egui::RichText::new(tl!("OK")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR));
|
||||
if okb.clicked() {
|
||||
r.0 = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
fn heading(ui: &mut Ui, s: &str) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||||
}
|
||||
|
||||
fn error(ui: &mut Ui, message: &str) {
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message).small().color(ui.visuals().error_fg_color));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Compare / Combine
|
||||
|
||||
/// The comparison settings (More >>), in the order the dialog shows them, with their
|
||||
/// `review.compare` parameter names.
|
||||
pub const COMPARE_SETTINGS: [(&str, &str); 10] = [
|
||||
("moves", "Moves"),
|
||||
("comments", "Comments"),
|
||||
("formatting", "Formatting"),
|
||||
("caseChanges", "Case changes"),
|
||||
("whiteSpace", "White space"),
|
||||
("tables", "Tables"),
|
||||
("headersFooters", "Headers and footers"),
|
||||
("footnotes", "Footnotes and endnotes"),
|
||||
("textBoxes", "Text boxes"),
|
||||
("fields", "Fields"),
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompareForm {
|
||||
pub combine: bool,
|
||||
/// The original is the open document (else the file `original`).
|
||||
pub original_current: bool,
|
||||
pub original: String,
|
||||
pub revised: String,
|
||||
/// Label changes with.
|
||||
pub author: String,
|
||||
/// More >> shown.
|
||||
pub more: bool,
|
||||
/// [`COMPARE_SETTINGS`], all on by default as in Word.
|
||||
pub settings: [bool; 10],
|
||||
pub character_level: bool,
|
||||
/// Show changes in: `original`, `revised` or `new`.
|
||||
pub show_in: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl CompareForm {
|
||||
pub fn new(app: &WordApp, combine: bool) -> CompareForm {
|
||||
CompareForm {
|
||||
combine,
|
||||
original_current: true,
|
||||
original: String::new(),
|
||||
revised: String::new(),
|
||||
author: app.session.author.clone(),
|
||||
more: false,
|
||||
settings: [true; 10],
|
||||
character_level: false,
|
||||
show_in: "new".into(),
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `review.compare` / `review.combine` parameters; `None` until a revised document is
|
||||
/// chosen.
|
||||
pub fn params(&self) -> Option<Value> {
|
||||
let revised = self.revised.trim();
|
||||
if revised.is_empty() || (!self.original_current && self.original.trim().is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let mut v = json!({
|
||||
"path": revised,
|
||||
"author": self.author.trim(),
|
||||
"level": if self.character_level { "character" } else { "word" },
|
||||
"showIn": self.show_in,
|
||||
});
|
||||
if !self.original_current {
|
||||
v["original"] = json!(self.original.trim());
|
||||
}
|
||||
for ((key, _), on) in COMPARE_SETTINGS.iter().zip(self.settings) {
|
||||
v[*key] = json!(on);
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this front end pick files (desktop), rather than only receive them (web)?
|
||||
fn can_browse(app: &WordApp) -> bool {
|
||||
app.services.file_dialog.is_some() || app.services.pick_open.is_some()
|
||||
}
|
||||
|
||||
/// Put a picked file into the open Compare dialog (`revised`: the revised document's box).
|
||||
pub(crate) fn set_compare_path(app: &mut WordApp, revised: bool, path: String) {
|
||||
if let Some(crate::dialogs::Dialog::Review(d)) = app.dialog.as_mut()
|
||||
&& let ReviewDialog::Compare(f) = &mut **d
|
||||
{
|
||||
if revised {
|
||||
f.revised = path;
|
||||
} else {
|
||||
f.original = path;
|
||||
f.original_current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
let browse = can_browse(app);
|
||||
let mut pick: Option<bool> = None;
|
||||
if !browse {
|
||||
ui.label(egui::RichText::new(tl!("Comparing with another file needs the desktop app.")).small().weak());
|
||||
}
|
||||
egui::Grid::new("compare_files").num_columns(2).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Original document");
|
||||
ui.radio_value(&mut f.original_current, true, tl!("This document"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut f.original_current, false, "");
|
||||
ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.original).desired_width(170.0).hint_text(tl!("Another file")));
|
||||
if ui.add_enabled(browse, egui::Button::new(tl!("Browse…"))).clicked() {
|
||||
pick = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Revised document");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.revised).desired_width(170.0));
|
||||
if ui.add_enabled(browse, egui::Button::new(tl!("Browse…"))).clicked() {
|
||||
pick = Some(true);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Label changes with:"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.author).desired_width(120.0));
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if ui.button(if f.more { tl!("<< Less") } else { tl!("More >>") }).clicked() {
|
||||
f.more = !f.more;
|
||||
}
|
||||
if f.more {
|
||||
heading(ui, "Comparison settings");
|
||||
egui::Grid::new("compare_settings").num_columns(2).spacing(vec2(16.0, 2.0)).show(ui, |ui| {
|
||||
for (i, (_, label)) in COMPARE_SETTINGS.iter().enumerate() {
|
||||
if let Some(on) = f.settings.get_mut(i) {
|
||||
ui.checkbox(on, tl!(label));
|
||||
}
|
||||
if i % 2 == 1 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(tl!("Formatting, case changes, white space and tables are compared; the other settings are kept for later."))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
egui::Grid::new("compare_show").num_columns(2).spacing(vec2(24.0, 2.0)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Show changes at:");
|
||||
ui.radio_value(&mut f.character_level, false, tl!("Word level"));
|
||||
ui.radio_value(&mut f.character_level, true, tl!("Character level"));
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Show changes in:");
|
||||
for (v, l) in [("original", "Original document"), ("revised", "Revised document"), ("new", "New document")] {
|
||||
ui.radio_value(&mut f.show_in, v.to_string(), tl!(l));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
if let Some(revised) = pick {
|
||||
// A blocking picker answers now, while this dialog is out of `app.dialog`: fill it here.
|
||||
if app.services.file_dialog.is_none()
|
||||
&& let Some(pick_open) = &app.services.pick_open
|
||||
{
|
||||
if let Some(path) = pick_open("document") {
|
||||
if revised {
|
||||
f.revised = path;
|
||||
} else {
|
||||
f.original = path;
|
||||
f.original_current = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = app.ask_file(
|
||||
crate::file_dialogs::FileDialogRequest::Open { purpose: "document".into() },
|
||||
crate::file_dialogs::AfterPick::ComparePath { revised },
|
||||
);
|
||||
}
|
||||
}
|
||||
error(ui, &f.message);
|
||||
let params = f.params();
|
||||
let (ok, cancel) = buttons(ui, params.is_some());
|
||||
if ok && let Some(params) = params {
|
||||
let id = if f.combine { "review.combine" } else { "review.compare" };
|
||||
match app.run(id, params) {
|
||||
Ok(v) => {
|
||||
let n = v.get("changes").and_then(Value::as_u64).unwrap_or(0).to_string();
|
||||
app.status(crate::i18n::fmt(tl!("Compared: {changes} changes"), &[("changes", &n)]));
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
f.message = e;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// AutoCorrect Options
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoCorrectForm {
|
||||
/// 0 AutoCorrect, 1 AutoFormat As You Type, 2 Math AutoCorrect.
|
||||
pub tab: u8,
|
||||
/// The options as edited (applied on OK); the lists change at once, as in Word.
|
||||
pub options: AutoCorrectPrefs,
|
||||
/// Replace / With boxes.
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// Exceptions shown, and the exception being typed.
|
||||
pub show_exceptions: bool,
|
||||
pub exception: String,
|
||||
/// Math AutoCorrect filter.
|
||||
pub math_filter: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl AutoCorrectForm {
|
||||
pub fn new(app: &WordApp) -> AutoCorrectForm {
|
||||
AutoCorrectForm {
|
||||
tab: 0,
|
||||
options: app.session.prefs.autocorrect.clone(),
|
||||
from: String::new(),
|
||||
to: String::new(),
|
||||
show_exceptions: false,
|
||||
exception: String::new(),
|
||||
math_filter: String::new(),
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `tools.autocorrect` switches.
|
||||
pub fn params(&self) -> Value {
|
||||
let o = &self.options;
|
||||
json!({
|
||||
"replaceText": o.replace_text,
|
||||
"capSentences": o.cap_sentences,
|
||||
"capCells": o.cap_cells,
|
||||
"capDays": o.cap_days,
|
||||
"smartQuotes": o.smart_quotes,
|
||||
"fractions": o.fractions,
|
||||
"ordinals": o.ordinals,
|
||||
"dashes": o.dashes,
|
||||
"links": o.links,
|
||||
"bullets": o.bullets,
|
||||
"numbering": o.numbering,
|
||||
"borderLines": o.border_lines,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a list change at once and take the lists it reports back.
|
||||
fn list_change(app: &mut WordApp, f: &mut AutoCorrectForm, params: Value) -> bool {
|
||||
match app.run("tools.autocorrect", params) {
|
||||
Ok(_) => {
|
||||
let now = app.session.prefs.autocorrect.clone();
|
||||
f.options.entries = now.entries;
|
||||
f.options.removed = now.removed;
|
||||
f.options.exceptions = now.exceptions;
|
||||
f.options.exceptions_removed = now.exceptions_removed;
|
||||
f.message.clear();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
f.message = e;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bool {
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in ["AutoCorrect", "AutoFormat As You Type", "Math AutoCorrect"].into_iter().enumerate() {
|
||||
if ui.selectable_label(f.tab == i as u8, tl!(l)).clicked() {
|
||||
f.tab = i as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
match f.tab {
|
||||
0 => autocorrect_tab(app, ui, f),
|
||||
1 => {
|
||||
let o = &mut f.options;
|
||||
heading(ui, "Replace as you type");
|
||||
ui.checkbox(&mut o.smart_quotes, tl!("Smart quotes instead of straight quotes"));
|
||||
ui.checkbox(&mut o.fractions, tl!("Fractions (1/2) as fraction characters (½)"));
|
||||
ui.checkbox(&mut o.ordinals, tl!("Ordinals (1st) as superscript"));
|
||||
ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)"));
|
||||
ui.checkbox(&mut o.links, tl!("Internet addresses as links"));
|
||||
heading(ui, "Apply as you type");
|
||||
ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists"));
|
||||
ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists"));
|
||||
ui.checkbox(&mut o.border_lines, tl!("Border lines"));
|
||||
}
|
||||
_ => math_tab(ui, f),
|
||||
}
|
||||
error(ui, &f.message);
|
||||
let (ok, cancel) = buttons(ui, true);
|
||||
if ok {
|
||||
if let Err(e) = app.run("tools.autocorrect", f.params()) {
|
||||
f.message = e;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
let o = &mut f.options;
|
||||
ui.checkbox(&mut o.cap_sentences, tl!("Capitalize first letter of sentences"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(18.0);
|
||||
if ui.small_button(tl!("Exceptions…")).clicked() {
|
||||
f.show_exceptions = !f.show_exceptions;
|
||||
}
|
||||
});
|
||||
if f.show_exceptions {
|
||||
exceptions(app, ui, f);
|
||||
}
|
||||
let o = &mut f.options;
|
||||
ui.checkbox(&mut o.cap_cells, tl!("Capitalize first letter of table cells"));
|
||||
ui.checkbox(&mut o.cap_days, tl!("Capitalize names of days"));
|
||||
ui.checkbox(&mut o.replace_text, tl!("Replace text as you type"));
|
||||
egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Replace:"));
|
||||
ui.label(tl!("With:"));
|
||||
ui.end_row();
|
||||
ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0));
|
||||
ui.end_row();
|
||||
});
|
||||
// The list, filtered to what's typed in Replace (as Word scrolls to it).
|
||||
let list = f.options.list();
|
||||
let typed = f.from.trim().to_lowercase();
|
||||
let mut picked: Option<(String, String)> = None;
|
||||
egui::ScrollArea::vertical().id_salt("ac_list").max_height(170.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true).show(ui, |ui| {
|
||||
for (a, b, _) in list.iter().filter(|(a, _, _)| typed.is_empty() || a.to_lowercase().starts_with(&typed)).take(500) {
|
||||
let selected = f.from == *a;
|
||||
if ui.add_sized(vec2(110.0, 16.0), egui::Button::selectable(selected, a.as_str())).clicked() {
|
||||
picked = Some((a.clone(), b.clone()));
|
||||
}
|
||||
ui.label(b.as_str());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some((a, b)) = picked {
|
||||
f.from = a;
|
||||
f.to = b;
|
||||
}
|
||||
let exists = list.iter().any(|(a, _, _)| *a == f.from);
|
||||
let same = list.iter().any(|(a, b, _)| *a == f.from && *b == f.to);
|
||||
ui.horizontal(|ui| {
|
||||
let label = if exists { tl!("Replace") } else { tl!("Add") };
|
||||
if ui.add_enabled(!f.from.trim().is_empty() && !same, egui::Button::new(label)).clicked() {
|
||||
let p = json!({"add": {"from": f.from, "to": f.to}});
|
||||
if list_change(app, f, p) {
|
||||
f.from.clear();
|
||||
f.to.clear();
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(exists, egui::Button::new(tl!("Delete"))).clicked() {
|
||||
let p = json!({"delete": f.from});
|
||||
if list_change(app, f, p) {
|
||||
f.from.clear();
|
||||
f.to.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn exceptions(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.label(tl!("Don't capitalize after:"));
|
||||
let list = f.options.exception_list();
|
||||
let mut picked = None;
|
||||
egui::ScrollArea::vertical().id_salt("ac_exceptions").max_height(90.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for e in &list {
|
||||
if ui.add(egui::Button::selectable(f.exception == *e, e.as_str())).clicked() {
|
||||
picked = Some(e.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(e) = picked {
|
||||
f.exception = e;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut f.exception).desired_width(110.0));
|
||||
let known = list.contains(&f.exception.trim().to_lowercase());
|
||||
if ui.add_enabled(!f.exception.trim().is_empty() && !known, egui::Button::new(tl!("Add"))).clicked() {
|
||||
let p = json!({"addException": f.exception});
|
||||
if list_change(app, f, p) {
|
||||
f.exception.clear();
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(known, egui::Button::new(tl!("Delete"))).clicked() {
|
||||
let p = json!({"deleteException": f.exception});
|
||||
if list_change(app, f, p) {
|
||||
f.exception.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
ui.label(egui::RichText::new(tl!("In equations, typing a name after a backslash replaces it with its symbol.")).small().weak());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Replace:"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.math_filter).desired_width(140.0));
|
||||
});
|
||||
let filter = f.math_filter.trim().trim_start_matches('\\').to_string();
|
||||
egui::ScrollArea::vertical().id_salt("ac_math").max_height(240.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("ac_math_grid").num_columns(2).spacing(vec2(16.0, 1.0)).striped(true).show(ui, |ui| {
|
||||
for (name, c) in wordcraft_doc::math_symbols::SYMBOLS.iter().filter(|(n, _)| filter.is_empty() || n.starts_with(filter.as_str())) {
|
||||
ui.label(format!("\\{name}"));
|
||||
ui.label(c.to_string());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Restrict Editing pane
|
||||
|
||||
/// The Restrict Editing pane's own state: what is being set up before protection starts, and
|
||||
/// the passwords being typed.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RestrictPane {
|
||||
pub open: bool,
|
||||
/// 1. Formatting restrictions: limit formatting to a selection of styles.
|
||||
pub formatting: bool,
|
||||
/// Its style list shown, and the styles allowed (by id); `None` until first shown (all).
|
||||
pub show_styles: bool,
|
||||
pub allowed: Option<Vec<String>>,
|
||||
/// 2. Editing restrictions on, and which (index into [`EDIT_MODES`]).
|
||||
pub editing: bool,
|
||||
pub mode: usize,
|
||||
/// 3. Asking for the password to start (or to stop) protection.
|
||||
pub asking: bool,
|
||||
pub password: Password,
|
||||
pub confirm: Password,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// The editing restrictions, with their `review.restrict` modes.
|
||||
pub const EDIT_MODES: [(&str, &str); 4] =
|
||||
[("readOnly", "No changes (read only)"), ("trackedChanges", "Tracked changes"), ("comments", "Comments"), ("forms", "Filling in forms")];
|
||||
|
||||
/// The styles the formatting restriction lists: paragraph and character styles not hidden.
|
||||
fn listed_styles(app: &WordApp) -> Vec<(String, String)> {
|
||||
use wordcraft_doc::StyleKind;
|
||||
let mut v: Vec<(String, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| matches!(s.kind, StyleKind::Paragraph | StyleKind::Character) && !s.hidden)
|
||||
.map(|s| (s.id.clone(), if s.name.is_empty() { s.id.clone() } else { s.name.clone() }))
|
||||
.collect();
|
||||
v.sort_by_key(|a| a.1.to_lowercase());
|
||||
v
|
||||
}
|
||||
|
||||
pub fn restrict_pane(app: &mut WordApp, ui: &mut Ui) {
|
||||
let mut st = std::mem::take(&mut app.restrict);
|
||||
let enforced = app.session.doc.settings.protection.clone();
|
||||
egui::ScrollArea::vertical().id_salt("restrict_pane").show(ui, |ui| match &enforced {
|
||||
Some(mode) => enforced_ui(app, ui, &mut st, mode),
|
||||
None => setup_ui(app, ui, &mut st),
|
||||
});
|
||||
app.restrict = st;
|
||||
}
|
||||
|
||||
fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) {
|
||||
heading(ui, "1. Formatting restrictions");
|
||||
ui.checkbox(&mut st.formatting, tl!("Limit formatting to a selection of styles"));
|
||||
if st.formatting && ui.small_button(tl!("Settings…")).clicked() {
|
||||
st.show_styles = !st.show_styles;
|
||||
}
|
||||
let styles = listed_styles(app);
|
||||
if st.allowed.is_none() {
|
||||
// Start from the document's own locked styles.
|
||||
let doc = &app.session.doc.styles;
|
||||
st.allowed = Some(styles.iter().filter(|(id, _)| !doc.get(id).is_some_and(|s| s.locked)).map(|(id, _)| id.clone()).collect());
|
||||
}
|
||||
if st.formatting && st.show_styles {
|
||||
let allowed = st.allowed.get_or_insert_with(Vec::new);
|
||||
ui.label(egui::RichText::new(tl!("Checked styles are allowed:")).small());
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button(tl!("All")).clicked() {
|
||||
*allowed = styles.iter().map(|(id, _)| id.clone()).collect();
|
||||
}
|
||||
if ui.small_button(tl!("None")).clicked() {
|
||||
allowed.clear();
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical().id_salt("restrict_styles").max_height(160.0).show(ui, |ui| {
|
||||
for (id, name) in &styles {
|
||||
let mut on = allowed.contains(id);
|
||||
if ui.checkbox(&mut on, tl!(name.as_str())).changed() {
|
||||
if on {
|
||||
allowed.push(id.clone());
|
||||
} else {
|
||||
allowed.retain(|x| x != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
heading(ui, "2. Editing restrictions");
|
||||
ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:"));
|
||||
ui.add_enabled_ui(st.editing, |ui| {
|
||||
let current = EDIT_MODES.get(st.mode).map(|m| m.1).unwrap_or("");
|
||||
egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(220.0).show_ui(ui, |ui| {
|
||||
for (i, (_, label)) in EDIT_MODES.iter().enumerate() {
|
||||
ui.selectable_value(&mut st.mode, i, tl!(label));
|
||||
}
|
||||
});
|
||||
});
|
||||
let mode = EDIT_MODES.get(st.mode).map(|m| m.0).unwrap_or("readOnly");
|
||||
if st.editing && matches!(mode, "readOnly" | "comments") {
|
||||
heading(ui, "Exceptions (optional)");
|
||||
ui.label(egui::RichText::new(tl!("Select text, then tick who can still edit it.")).small());
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let inside = app.session.doc.perm_ranges().iter().any(|r| r.everyone() && r.start <= a && b <= r.end && r.start.story == a.story);
|
||||
let mut everyone = inside;
|
||||
let can = inside || a != b;
|
||||
if ui.add_enabled(can, egui::Checkbox::new(&mut everyone, tl!("Everyone"))).changed() {
|
||||
let p = if everyone { json!({"exception": "add"}) } else { json!({"exception": "remove"}) };
|
||||
if let Err(e) = app.run("review.restrict", p) {
|
||||
st.message = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
heading(ui, "3. Start enforcement");
|
||||
let ready = st.formatting || st.editing;
|
||||
if !st.asking {
|
||||
if ui.add_enabled(ready, egui::Button::new(tl!("Start Enforcing Protection"))).clicked() {
|
||||
st.asking = true;
|
||||
st.message.clear();
|
||||
}
|
||||
} else {
|
||||
ui.label(egui::RichText::new(tl!("The password is optional; without it anyone can stop the protection.")).small());
|
||||
passwords(ui, st, true);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("OK")).clicked() {
|
||||
if st.password != st.confirm {
|
||||
st.message = tl!("The passwords don't match.").to_string();
|
||||
st.confirm.as_mut_string().clear();
|
||||
} else {
|
||||
let mut p =
|
||||
json!({"mode": if st.editing { mode } else { "none" }, "formatting": st.formatting, "password": st.password.as_str()});
|
||||
if st.formatting {
|
||||
let allowed = st.allowed.clone().unwrap_or_default();
|
||||
p["lockedStyles"] =
|
||||
json!(styles.iter().filter(|(id, _)| !allowed.contains(id)).map(|(id, _)| id.clone()).collect::<Vec<_>>());
|
||||
}
|
||||
match app.run("review.restrict", p) {
|
||||
Ok(_) => {
|
||||
st.asking = false;
|
||||
st.message.clear();
|
||||
}
|
||||
Err(e) => st.message = e,
|
||||
}
|
||||
st.password = Password::default();
|
||||
st.confirm = Password::default();
|
||||
}
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
st.asking = false;
|
||||
st.password = Password::default();
|
||||
st.confirm = Password::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
error(ui, &st.message);
|
||||
}
|
||||
|
||||
fn passwords(ui: &mut Ui, st: &mut RestrictPane, confirm: bool) {
|
||||
egui::Grid::new("restrict_pw").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Password:"));
|
||||
ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(140.0));
|
||||
ui.end_row();
|
||||
if confirm {
|
||||
ui.label(tl!("Confirm password:"));
|
||||
ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(140.0));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str) {
|
||||
ui.label(tl!("Editing in this document is restricted."));
|
||||
ui.add_space(4.0);
|
||||
let what = match mode {
|
||||
"readOnly" => "The document is read only, except for regions marked editable.",
|
||||
"comments" => "Only comments can be added, except in regions marked editable.",
|
||||
"trackedChanges" => "Every change is recorded as a tracked change.",
|
||||
"forms" => "Only form fields can be filled in.",
|
||||
_ => "Only the allowed styles can be applied.",
|
||||
};
|
||||
ui.label(egui::RichText::new(tl!(what)).small());
|
||||
let regions = app.session.doc.perm_ranges().iter().filter(|r| r.everyone() || r.editor.eq_ignore_ascii_case(&app.session.author)).count();
|
||||
if regions > 0 {
|
||||
ui.add_space(6.0);
|
||||
if ui.button(tl!("Find Next Editable Region")).clicked() {
|
||||
let from = app.session.sel.ordered().1;
|
||||
if let Some((a, b)) = wordcraft_engine::cmd::protect::next_region(&app.session, &from) {
|
||||
app.session.sel = wordcraft_engine::Selection { anchor: a, focus: b };
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let has_password = !app.session.doc.settings.protect_hash.is_empty();
|
||||
if !st.asking {
|
||||
if ui.button(tl!("Stop Protection")).clicked() {
|
||||
if has_password {
|
||||
st.asking = true;
|
||||
st.message.clear();
|
||||
} else {
|
||||
stop(app, st);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.label(egui::RichText::new(tl!("Type the password to stop the protection.")).small());
|
||||
passwords(ui, st, false);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("OK")).clicked() {
|
||||
stop(app, st);
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
st.asking = false;
|
||||
st.password = Password::default();
|
||||
}
|
||||
});
|
||||
}
|
||||
error(ui, &st.message);
|
||||
}
|
||||
|
||||
fn stop(app: &mut WordApp, st: &mut RestrictPane) {
|
||||
let r = app.run("review.restrict", json!({"stop": true, "password": st.password.as_str()}));
|
||||
st.password = Password::default();
|
||||
match r {
|
||||
Ok(_) => {
|
||||
st.asking = false;
|
||||
st.message.clear();
|
||||
}
|
||||
Err(e) if e.contains(wordcraft_engine::cmd::protect::UNSUPPORTED_PASSWORD) => {
|
||||
st.message = tl!("This password type isn't supported.").to_string();
|
||||
}
|
||||
Err(e) => st.message = e,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, WordApp};
|
||||
|
||||
fn app(text: &str) -> WordApp {
|
||||
WordApp::new(Session::new(wordcraft_doc::Document::from_text(text)), Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_entries_persist_in_prefs_and_junk_is_ignored() {
|
||||
let mut a = app("");
|
||||
a.run("tools.autocorrect", json!({"add": {"from": "brb", "to": "be right back"}, "capDays": false})).unwrap();
|
||||
let saved = serde_json::to_string(&a.prefs()).unwrap();
|
||||
let mut b = app("");
|
||||
b.apply_prefs(serde_json::from_str(&saved).unwrap());
|
||||
assert_eq!(b.session.prefs.autocorrect.replacement("brb").as_deref(), Some("be right back"));
|
||||
assert!(!b.session.prefs.autocorrect.cap_days);
|
||||
for c in "brb ".chars() {
|
||||
b.session.run("text.insert", &json!({"text": c.to_string()})).unwrap();
|
||||
}
|
||||
assert_eq!(b.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Be right back ");
|
||||
// A damaged AutoCorrect section reads as the defaults; the rest of the file still loads.
|
||||
let mut v: Value = serde_json::from_str(&saved).unwrap();
|
||||
v["editing"]["autocorrect"] = json!({"entries": 7, "capDays": "x"});
|
||||
v["author"] = json!("Ada");
|
||||
let mut c = app("");
|
||||
c.apply_prefs(serde_json::from_value(v).unwrap());
|
||||
assert_eq!(c.session.prefs.autocorrect, AutoCorrectPrefs::default());
|
||||
assert_eq!(c.session.author, "Ada");
|
||||
// Hostile entries are dropped on load.
|
||||
let mut v: Value = serde_json::from_str(&saved).unwrap();
|
||||
v["editing"]["autocorrect"]["entries"] = json!([["", "x"], ["ok", "fine"], ["ok", "dup"], ["a\nb", "x"]]);
|
||||
let mut d = app("");
|
||||
d.apply_prefs(serde_json::from_value(v).unwrap());
|
||||
assert_eq!(d.session.prefs.autocorrect.entries, vec![("ok".to_string(), "fine".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_open_by_name_and_run_their_commands() {
|
||||
let mut a = app("Original text.");
|
||||
a.run("review.compare", json!({})).unwrap();
|
||||
assert_eq!(a.dialog.as_ref().map(|d| d.name()), Some("compare"));
|
||||
a.run("review.combine", json!({})).unwrap();
|
||||
assert_eq!(a.dialog.as_ref().map(|d| d.name()), Some("combine"));
|
||||
a.run("tools.autocorrect", json!({})).unwrap();
|
||||
assert_eq!(a.dialog.as_ref().map(|d| d.name()), Some("autoCorrect"));
|
||||
a.dialog = None;
|
||||
// Restrict Editing without settings opens the pane and protects nothing.
|
||||
a.run("review.restrict", json!({})).unwrap();
|
||||
assert!(a.restrict.open && a.dialog.is_none());
|
||||
assert!(a.session.doc.settings.protection.is_none());
|
||||
// The form's parameters carry every comparison setting.
|
||||
let mut f = CompareForm::new(&a, false);
|
||||
assert!(f.params().is_none());
|
||||
f.revised = "/tmp/revised.docx".into();
|
||||
f.settings[3] = false;
|
||||
let p = f.params().unwrap();
|
||||
assert_eq!(p["caseChanges"], false);
|
||||
assert_eq!(p["showIn"], "new");
|
||||
assert!(p.get("original").is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ pub(crate) enum AfterPick {
|
|||
Export { ext: String },
|
||||
/// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334).
|
||||
Recipients,
|
||||
/// Put the picked document into the Compare dialog (original or revised, #412).
|
||||
ComparePath { revised: bool },
|
||||
}
|
||||
|
||||
/// What to do once Save As has written the document.
|
||||
|
|
@ -127,6 +129,11 @@ impl WordApp {
|
|||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
self.load_recipients(json!({"path": path}))
|
||||
}
|
||||
AfterPick::ComparePath { revised } => {
|
||||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
crate::dialogs_review::set_compare_path(self, revised, path);
|
||||
Ok(json!({"picked": true}))
|
||||
}
|
||||
AfterPick::Export { ext } => {
|
||||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
let r = if ext == "png" { self.run("file.exportPng", json!({"path": path})) } else { self.run("file.saveAs", json!({"path": path})) };
|
||||
|
|
|
|||
|
|
@ -1366,6 +1366,80 @@
|
|||
Formatted Con formato
|
||||
Interface size: Tamaño de la interfaz:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte.
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. Restricciones de formato
|
||||
2. Editing restrictions 2. Restricciones de edición
|
||||
3. Start enforcement 3. Aplicar la protección
|
||||
<< Less << Menos
|
||||
Another file Otro archivo
|
||||
Apply as you type Aplicar mientras escribe
|
||||
AutoCorrect Autocorrección
|
||||
AutoFormat As You Type Autoformato al escribir
|
||||
Automatic bulleted lists Listas con viñetas automáticas
|
||||
Automatic numbered lists Listas numeradas automáticas
|
||||
Border lines Líneas de borde
|
||||
Capitalize first letter of sentences Poner en mayúscula la primera letra de las oraciones
|
||||
Capitalize first letter of table cells Poner en mayúscula la primera letra de las celdas
|
||||
Capitalize names of days Poner en mayúscula los nombres de los días
|
||||
Case changes Cambios de mayúsculas
|
||||
Character level Nivel de carácter
|
||||
Checked styles are allowed: Los estilos marcados están permitidos:
|
||||
Combine Documents Combinar documentos
|
||||
Compare Documents Comparar documentos
|
||||
Compared: {changes} changes Comparado: {changes} cambios
|
||||
Comparing with another file needs the desktop app. Para comparar con otro archivo se necesita la aplicación de escritorio.
|
||||
Comparison settings Configuración de comparación
|
||||
Don't capitalize after: No poner mayúscula después de:
|
||||
Editing in this document is restricted. La edición de este documento está restringida.
|
||||
Every change is recorded as a tracked change. Cada cambio se registra como cambio con seguimiento.
|
||||
Everyone Todos
|
||||
Exceptions (optional) Excepciones (opcional)
|
||||
Exceptions… Excepciones…
|
||||
Fields Campos
|
||||
Filling in forms Rellenar formularios
|
||||
Find Next Editable Region Buscar la siguiente región editable
|
||||
Footnotes and endnotes Notas al pie y notas al final
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Se comparan el formato, las mayúsculas, los espacios en blanco y las tablas; los demás ajustes se guardan para más adelante.
|
||||
Fractions (1/2) as fraction characters (½) Fracciones (1/2) como caracteres de fracción (½)
|
||||
Headers and footers Encabezados y pies de página
|
||||
Hyphens (--) as dashes (—) Guiones (--) como rayas (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. En las ecuaciones, escribir un nombre después de una barra invertida lo sustituye por su símbolo.
|
||||
Internet addresses as links Direcciones de Internet como vínculos
|
||||
Label changes with: Etiquetar cambios con:
|
||||
Limit formatting to a selection of styles Limitar el formato a una selección de estilos
|
||||
Math AutoCorrect Autocorrección matemática
|
||||
More >> Más >>
|
||||
Moves Movimientos
|
||||
New document Documento nuevo
|
||||
No changes (read only) Sin cambios (solo lectura)
|
||||
Only allow this kind of editing: Permitir solo este tipo de edición:
|
||||
Only comments can be added, except in regions marked editable. Solo se pueden añadir comentarios, salvo en las regiones marcadas como editables.
|
||||
Only form fields can be filled in. Solo se pueden rellenar los campos de formulario.
|
||||
Only the allowed styles can be applied. Solo se pueden aplicar los estilos permitidos.
|
||||
Ordinals (1st) as superscript Ordinales (1st) como superíndice
|
||||
Original document Documento original
|
||||
This password type isn't supported. Este tipo de contraseña no es compatible.
|
||||
Replace as you type Reemplazar mientras escribe
|
||||
Replace text as you type Reemplazar texto mientras escribe
|
||||
Replace: Reemplazar:
|
||||
Revised document Documento revisado
|
||||
Select text, then tick who can still edit it. Selecciona texto y marca quién puede seguir editándolo.
|
||||
Settings… Configuración…
|
||||
Show changes at: Mostrar cambios a nivel de:
|
||||
Show changes in: Mostrar cambios en:
|
||||
Smart quotes instead of straight quotes Comillas tipográficas en lugar de rectas
|
||||
Start Enforcing Protection Aplicar la protección
|
||||
Stop Protection Detener protección
|
||||
Text boxes Cuadros de texto
|
||||
The document is read only, except for regions marked editable. El documento es de solo lectura, salvo las regiones marcadas como editables.
|
||||
The password is optional; without it anyone can stop the protection. La contraseña es opcional; sin ella, cualquiera puede detener la protección.
|
||||
This document Este documento
|
||||
Tracked changes Cambios con seguimiento
|
||||
Type the password to stop the protection. Escribe la contraseña para detener la protección.
|
||||
White space Espacios en blanco
|
||||
With: Con:
|
||||
Word level Nivel de palabra
|
||||
In Line with Text En línea con el texto
|
||||
Square Cuadrado
|
||||
Through Transparente
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1351,6 +1351,80 @@
|
|||
Formatted Vormindatud
|
||||
Interface size: Kasutajaliidese suurus:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi.
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. Vormingupiirangud
|
||||
2. Editing restrictions 2. Muutmispiirangud
|
||||
3. Start enforcement 3. Kaitse jõustamine
|
||||
<< Less << Vähem
|
||||
Another file Muu fail
|
||||
Apply as you type Rakenda tippimise ajal
|
||||
AutoCorrect Automaatparandus
|
||||
AutoFormat As You Type Automaatvorming tippimisel
|
||||
Automatic bulleted lists Automaatsed täpploendid
|
||||
Automatic numbered lists Automaatsed nummerloendid
|
||||
Border lines Äärisjooned
|
||||
Capitalize first letter of sentences Lausete esitäht suureks
|
||||
Capitalize first letter of table cells Tabelilahtrite esitäht suureks
|
||||
Capitalize names of days Nädalapäevade nimed suure algustähega
|
||||
Case changes Tähesuuruse muutused
|
||||
Character level Märgi tasandil
|
||||
Checked styles are allowed: Märgitud laadid on lubatud:
|
||||
Combine Documents Ühenda dokumendid
|
||||
Compare Documents Võrdle dokumente
|
||||
Compared: {changes} changes Võrreldud: {changes} muudatust
|
||||
Comparing with another file needs the desktop app. Teise failiga võrdlemiseks on vaja töölauarakendust.
|
||||
Comparison settings Võrdlemise sätted
|
||||
Don't capitalize after: Ära muuda suureks pärast:
|
||||
Editing in this document is restricted. Selle dokumendi muutmine on piiratud.
|
||||
Every change is recorded as a tracked change. Iga muudatus salvestatakse jälitatud muudatusena.
|
||||
Everyone Kõik
|
||||
Exceptions (optional) Erandid (valikuline)
|
||||
Exceptions… Erandid…
|
||||
Fields Väljad
|
||||
Filling in forms Vormide täitmine
|
||||
Find Next Editable Region Leia järgmine muudetav piirkond
|
||||
Footnotes and endnotes Allmärkused ja lõpumärkused
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Võrreldakse vormingut, tähesuurust, tühikuid ja tabeleid; ülejäänud sätted jäävad hilisemaks.
|
||||
Fractions (1/2) as fraction characters (½) Murrud (1/2) murrumärkidena (½)
|
||||
Headers and footers Päised ja jalused
|
||||
Hyphens (--) as dashes (—) Sidekriipsud (--) mõttekriipsudena (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. Võrrandites asendatakse kaldkriipsu järel tipitud nimi selle sümboliga.
|
||||
Internet addresses as links Internetiaadressid linkidena
|
||||
Label changes with: Muudatuste silt:
|
||||
Limit formatting to a selection of styles Piira vorming valitud laadidega
|
||||
Math AutoCorrect Matemaatika automaatparandus
|
||||
More >> Rohkem >>
|
||||
Moves Teisaldused
|
||||
New document Uus dokument
|
||||
No changes (read only) Muudatusi pole (kirjutuskaitstud)
|
||||
Only allow this kind of editing: Luba ainult seda tüüpi muutmist:
|
||||
Only comments can be added, except in regions marked editable. Lisada saab ainult kommentaare, välja arvatud muudetavaks märgitud piirkondades.
|
||||
Only form fields can be filled in. Täita saab ainult vormivälju.
|
||||
Only the allowed styles can be applied. Rakendada saab ainult lubatud laade.
|
||||
Ordinals (1st) as superscript Järgarvud (1st) ülaindeksina
|
||||
Original document Algdokument
|
||||
This password type isn't supported. Seda paroolitüüpi ei toetata.
|
||||
Replace as you type Asenda tippimise ajal
|
||||
Replace text as you type Asenda tekst tippimise ajal
|
||||
Replace: Asenda:
|
||||
Revised document Muudetud dokument
|
||||
Select text, then tick who can still edit it. Vali tekst ja märgi, kes saab seda veel muuta.
|
||||
Settings… Sätted…
|
||||
Show changes at: Kuva muudatused tasandil:
|
||||
Show changes in: Kuva muudatused:
|
||||
Smart quotes instead of straight quotes Nutikad jutumärgid sirgete asemel
|
||||
Start Enforcing Protection Jõusta kaitse
|
||||
Stop Protection Lõpeta kaitse
|
||||
Text boxes Tekstiväljad
|
||||
The document is read only, except for regions marked editable. Dokument on kirjutuskaitstud, välja arvatud muudetavaks märgitud piirkonnad.
|
||||
The password is optional; without it anyone can stop the protection. Parool on valikuline; ilma selleta saab igaüks kaitse lõpetada.
|
||||
This document See dokument
|
||||
Tracked changes Jälitatud muudatused
|
||||
Type the password to stop the protection. Kaitse lõpetamiseks sisesta parool.
|
||||
White space Tühikud
|
||||
With: Millega:
|
||||
Word level Sõna tasandil
|
||||
In Line with Text Tekstiga reas
|
||||
Square Ruudukujuline
|
||||
Through Läbiv
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -1350,6 +1350,80 @@
|
|||
Formatted 書式変更
|
||||
Interface size: 表示サイズ:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. 書式の制限
|
||||
2. Editing restrictions 2. 編集の制限
|
||||
3. Start enforcement 3. 保護の開始
|
||||
<< Less << 表示を減らす
|
||||
Another file 別のファイル
|
||||
Apply as you type 入力中に適用
|
||||
AutoCorrect オートコレクト
|
||||
AutoFormat As You Type 入力オートフォーマット
|
||||
Automatic bulleted lists 箇条書き (自動)
|
||||
Automatic numbered lists 段落番号 (自動)
|
||||
Border lines 罫線
|
||||
Capitalize first letter of sentences 文の先頭文字を大文字にする
|
||||
Capitalize first letter of table cells 表のセルの先頭文字を大文字にする
|
||||
Capitalize names of days 曜日の先頭文字を大文字にする
|
||||
Case changes 大文字/小文字の変更
|
||||
Character level 文字単位
|
||||
Checked styles are allowed: チェックしたスタイルを使用可能:
|
||||
Combine Documents 文書の組み込み
|
||||
Compare Documents 文書の比較
|
||||
Compared: {changes} changes 比較しました: 変更 {changes} 件
|
||||
Comparing with another file needs the desktop app. 別のファイルとの比較にはデスクトップ版が必要です。
|
||||
Comparison settings 比較の設定
|
||||
Don't capitalize after: 次の後は大文字にしない:
|
||||
Editing in this document is restricted. この文書の編集は制限されています。
|
||||
Every change is recorded as a tracked change. すべての変更が変更履歴として記録されます。
|
||||
Everyone すべてのユーザー
|
||||
Exceptions (optional) 例外 (省略可能)
|
||||
Exceptions… 例外…
|
||||
Fields フィールド
|
||||
Filling in forms フォームへの入力
|
||||
Find Next Editable Region 次の編集可能な範囲を検索
|
||||
Footnotes and endnotes 脚注と文末脚注
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. 書式、大文字/小文字、空白、表を比較します。その他の設定は今後の対応です。
|
||||
Fractions (1/2) as fraction characters (½) 分数 (1/2) を分数文字 (½) に
|
||||
Headers and footers ヘッダーとフッター
|
||||
Hyphens (--) as dashes (—) ハイフン (--) をダッシュ (—) に
|
||||
In equations, typing a name after a backslash replaces it with its symbol. 数式では、円記号 (バックスラッシュ) の後に名前を入力すると記号に置き換わります。
|
||||
Internet addresses as links インターネットのアドレスをリンクに
|
||||
Label changes with: 変更者のラベル:
|
||||
Limit formatting to a selection of styles 書式を選択したスタイルに制限する
|
||||
Math AutoCorrect 数式オートコレクト
|
||||
More >> オプション >>
|
||||
Moves 移動
|
||||
New document 新規文書
|
||||
No changes (read only) 変更不可 (読み取り専用)
|
||||
Only allow this kind of editing: 次の種類の編集のみを許可:
|
||||
Only comments can be added, except in regions marked editable. 編集可能とマークされた範囲を除き、コメントの追加のみ可能です。
|
||||
Only form fields can be filled in. フォーム フィールドへの入力のみ可能です。
|
||||
Only the allowed styles can be applied. 使用を許可されたスタイルだけを適用できます。
|
||||
Ordinals (1st) as superscript 序数 (1st) を上付き文字に
|
||||
Original document 元の文書
|
||||
This password type isn't supported. この種類のパスワードはサポートされていません。
|
||||
Replace as you type 入力中に自動で変更
|
||||
Replace text as you type 入力中に自動修正する
|
||||
Replace: 修正文字列:
|
||||
Revised document 変更された文書
|
||||
Select text, then tick who can still edit it. テキストを選択し、編集できるユーザーをチェックします。
|
||||
Settings… 設定…
|
||||
Show changes at: 変更の表示単位:
|
||||
Show changes in: 変更の表示先:
|
||||
Smart quotes instead of straight quotes 左右の区別がない引用符を区別がある引用符に
|
||||
Start Enforcing Protection 保護を開始
|
||||
Stop Protection 保護の中止
|
||||
Text boxes テキスト ボックス
|
||||
The document is read only, except for regions marked editable. 編集可能とマークされた範囲を除き、この文書は読み取り専用です。
|
||||
The password is optional; without it anyone can stop the protection. パスワードは省略できます。省略すると、誰でも保護を中止できます。
|
||||
This document この文書
|
||||
Tracked changes 変更履歴
|
||||
Type the password to stop the protection. 保護を中止するにはパスワードを入力してください。
|
||||
White space 空白
|
||||
With: 修正後の文字列:
|
||||
Word level 単語単位
|
||||
In Line with Text 行内
|
||||
Square 四角形
|
||||
Through 内部
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -1362,6 +1362,80 @@
|
|||
Formatted Formatado
|
||||
Interface size: Tamanho da interface:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado.
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. Restrições de formatação
|
||||
2. Editing restrictions 2. Restrições de edição
|
||||
3. Start enforcement 3. Aplicar a proteção
|
||||
<< Less << Menos
|
||||
Another file Outro arquivo
|
||||
Apply as you type Aplicar ao digitar
|
||||
AutoCorrect Autocorreção
|
||||
AutoFormat As You Type AutoFormatação ao digitar
|
||||
Automatic bulleted lists Listas com marcadores automáticas
|
||||
Automatic numbered lists Listas numeradas automáticas
|
||||
Border lines Linhas de borda
|
||||
Capitalize first letter of sentences Primeira letra das frases em maiúscula
|
||||
Capitalize first letter of table cells Primeira letra das células em maiúscula
|
||||
Capitalize names of days Nomes dos dias em maiúscula
|
||||
Case changes Alterações de maiúsculas
|
||||
Character level Nível de caractere
|
||||
Checked styles are allowed: Os estilos marcados são permitidos:
|
||||
Combine Documents Combinar documentos
|
||||
Compare Documents Comparar documentos
|
||||
Compared: {changes} changes Comparado: {changes} alterações
|
||||
Comparing with another file needs the desktop app. Para comparar com outro arquivo é preciso o aplicativo para desktop.
|
||||
Comparison settings Configurações de comparação
|
||||
Don't capitalize after: Não colocar maiúscula depois de:
|
||||
Editing in this document is restricted. A edição deste documento está restrita.
|
||||
Every change is recorded as a tracked change. Cada alteração é registrada como alteração controlada.
|
||||
Everyone Todos
|
||||
Exceptions (optional) Exceções (opcional)
|
||||
Exceptions… Exceções…
|
||||
Fields Campos
|
||||
Filling in forms Preenchimento de formulários
|
||||
Find Next Editable Region Localizar a próxima região editável
|
||||
Footnotes and endnotes Notas de rodapé e notas de fim
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Formatação, maiúsculas, espaços em branco e tabelas são comparados; as outras configurações ficam para depois.
|
||||
Fractions (1/2) as fraction characters (½) Frações (1/2) como caracteres de fração (½)
|
||||
Headers and footers Cabeçalhos e rodapés
|
||||
Hyphens (--) as dashes (—) Hífens (--) como travessões (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. Nas equações, digitar um nome após uma barra invertida o substitui pelo símbolo.
|
||||
Internet addresses as links Endereços da Internet como links
|
||||
Label changes with: Rotular alterações com:
|
||||
Limit formatting to a selection of styles Limitar a formatação a uma seleção de estilos
|
||||
Math AutoCorrect Autocorreção de matemática
|
||||
More >> Mais >>
|
||||
Moves Movimentações
|
||||
New document Novo documento
|
||||
No changes (read only) Sem alterações (somente leitura)
|
||||
Only allow this kind of editing: Permitir somente este tipo de edição:
|
||||
Only comments can be added, except in regions marked editable. Somente comentários podem ser adicionados, exceto nas regiões marcadas como editáveis.
|
||||
Only form fields can be filled in. Somente os campos de formulário podem ser preenchidos.
|
||||
Only the allowed styles can be applied. Somente os estilos permitidos podem ser aplicados.
|
||||
Ordinals (1st) as superscript Ordinais (1st) sobrescritos
|
||||
Original document Documento original
|
||||
This password type isn't supported. Este tipo de senha não é compatível.
|
||||
Replace as you type Substituir ao digitar
|
||||
Replace text as you type Substituir texto ao digitar
|
||||
Replace: Substituir:
|
||||
Revised document Documento revisado
|
||||
Select text, then tick who can still edit it. Selecione o texto e marque quem ainda pode editá-lo.
|
||||
Settings… Configurações…
|
||||
Show changes at: Mostrar alterações no nível de:
|
||||
Show changes in: Mostrar alterações em:
|
||||
Smart quotes instead of straight quotes Aspas inteligentes em vez de retas
|
||||
Start Enforcing Protection Aplicar a proteção
|
||||
Stop Protection Parar proteção
|
||||
Text boxes Caixas de texto
|
||||
The document is read only, except for regions marked editable. O documento é somente leitura, exceto nas regiões marcadas como editáveis.
|
||||
The password is optional; without it anyone can stop the protection. A senha é opcional; sem ela, qualquer pessoa pode parar a proteção.
|
||||
This document Este documento
|
||||
Tracked changes Alterações controladas
|
||||
Type the password to stop the protection. Digite a senha para parar a proteção.
|
||||
White space Espaços em branco
|
||||
With: Por:
|
||||
Word level Nível de palavra
|
||||
In Line with Text Alinhado ao texto
|
||||
Square Quadrado
|
||||
Through Através
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1975,6 +1975,78 @@ preview Title Пример
|
|||
Search icons Поиск значков
|
||||
No icons match your search. Нет значков, соответствующих поиску.
|
||||
Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок
|
||||
1. Formatting restrictions 1. Ограничения форматирования
|
||||
2. Editing restrictions 2. Ограничения редактирования
|
||||
3. Start enforcement 3. Включение защиты
|
||||
<< Less << Меньше
|
||||
Another file Другой файл
|
||||
Apply as you type Применять при вводе
|
||||
AutoCorrect Автозамена
|
||||
AutoFormat As You Type Автоформат при вводе
|
||||
Automatic bulleted lists Автоматические маркированные списки
|
||||
Automatic numbered lists Автоматические нумерованные списки
|
||||
Border lines Линии границ
|
||||
Capitalize first letter of sentences Делать первые буквы предложений прописными
|
||||
Capitalize first letter of table cells Делать первые буквы ячеек таблиц прописными
|
||||
Capitalize names of days Названия дней с прописной буквы
|
||||
Case changes Изменения регистра
|
||||
Character level На уровне символов
|
||||
Checked styles are allowed: Разрешённые стили (отмечены):
|
||||
Combine Documents Объединение документов
|
||||
Compare Documents Сравнение документов
|
||||
Compared: {changes} changes Сравнение завершено: изменений — {changes}
|
||||
Comparing with another file needs the desktop app. Для сравнения с другим файлом нужно настольное приложение.
|
||||
Comparison settings Параметры сравнения
|
||||
Don't capitalize after: Не делать прописной после:
|
||||
Editing in this document is restricted. Редактирование этого документа ограничено.
|
||||
Every change is recorded as a tracked change. Каждое изменение записывается как исправление.
|
||||
Everyone Все
|
||||
Exceptions (optional) Исключения (необязательно)
|
||||
Exceptions… Исключения…
|
||||
Fields Поля
|
||||
Filling in forms Заполнение форм
|
||||
Find Next Editable Region Найти следующую область для редактирования
|
||||
Footnotes and endnotes Сноски и концевые сноски
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Сравниваются форматирование, регистр, пробелы и таблицы; остальные параметры сохраняются на будущее.
|
||||
Fractions (1/2) as fraction characters (½) Дроби (1/2) как символы дробей (½)
|
||||
Headers and footers Колонтитулы
|
||||
Hyphens (--) as dashes (—) Дефисы (--) как тире (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. В формулах имя после обратной косой черты заменяется символом.
|
||||
Internet addresses as links Адреса в Интернете как ссылки
|
||||
Label changes with: Подписывать изменения:
|
||||
Limit formatting to a selection of styles Ограничить форматирование выбранными стилями
|
||||
Math AutoCorrect Автозамена математическими символами
|
||||
More >> Больше >>
|
||||
Moves Перемещения
|
||||
New document Новый документ
|
||||
No changes (read only) Без изменений (только чтение)
|
||||
Only allow this kind of editing: Разрешить только такой способ редактирования:
|
||||
Only comments can be added, except in regions marked editable. Можно только добавлять примечания, кроме областей, помеченных как редактируемые.
|
||||
Only form fields can be filled in. Можно заполнять только поля форм.
|
||||
Only the allowed styles can be applied. Можно применять только разрешённые стили.
|
||||
Ordinals (1st) as superscript Порядковые числительные (1st) как надстрочные
|
||||
Original document Исходный документ
|
||||
This password type isn't supported. Этот тип пароля не поддерживается.
|
||||
Replace as you type Заменять при вводе
|
||||
Replace text as you type Заменять текст при вводе
|
||||
Replace: Заменить:
|
||||
Revised document Изменённый документ
|
||||
Select text, then tick who can still edit it. Выделите текст и отметьте, кто может его редактировать.
|
||||
Settings… Параметры…
|
||||
Show changes at: Показывать изменения:
|
||||
Show changes in: Показывать изменения в:
|
||||
Smart quotes instead of straight quotes «Умные» кавычки вместо прямых
|
||||
Start Enforcing Protection Включить защиту
|
||||
Stop Protection Отключить защиту
|
||||
Text boxes Надписи
|
||||
The document is read only, except for regions marked editable. Документ доступен только для чтения, кроме областей, помеченных как редактируемые.
|
||||
The password is optional; without it anyone can stop the protection. Пароль необязателен; без него любой может отключить защиту.
|
||||
This document Этот документ
|
||||
Tracked changes Исправления
|
||||
Type the password to stop the protection. Введите пароль, чтобы отключить защиту.
|
||||
White space Пробелы
|
||||
With: На:
|
||||
Word level На уровне слов
|
||||
Change Chart Type Изменить тип диаграммы
|
||||
Chart Design Конструктор диаграмм
|
||||
Type Тип
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -1360,6 +1360,80 @@
|
|||
Formatted Formatirano
|
||||
Interface size: Veličina interfejsa:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno.
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. Ograničenja oblikovanja
|
||||
2. Editing restrictions 2. Ograničenja uređivanja
|
||||
3. Start enforcement 3. Pokretanje zaštite
|
||||
<< Less << Manje
|
||||
Another file Druga datoteka
|
||||
Apply as you type Primeni dok kucaš
|
||||
AutoCorrect Automatska ispravka
|
||||
AutoFormat As You Type Automatsko oblikovanje tokom kucanja
|
||||
Automatic bulleted lists Automatske liste sa znakovima
|
||||
Automatic numbered lists Automatske numerisane liste
|
||||
Border lines Linije ivica
|
||||
Capitalize first letter of sentences Veliko prvo slovo rečenice
|
||||
Capitalize first letter of table cells Veliko prvo slovo u ćelijama tabele
|
||||
Capitalize names of days Veliko slovo u nazivima dana
|
||||
Case changes Promene veličine slova
|
||||
Character level Nivo znaka
|
||||
Checked styles are allowed: Označeni stilovi su dozvoljeni:
|
||||
Combine Documents Spajanje dokumenata
|
||||
Compare Documents Upoređivanje dokumenata
|
||||
Compared: {changes} changes Upoređeno: izmena {changes}
|
||||
Comparing with another file needs the desktop app. Za poređenje sa drugom datotekom potrebna je desktop aplikacija.
|
||||
Comparison settings Podešavanja poređenja
|
||||
Don't capitalize after: Bez velikog slova posle:
|
||||
Editing in this document is restricted. Uređivanje ovog dokumenta je ograničeno.
|
||||
Every change is recorded as a tracked change. Svaka izmena se beleži kao praćena izmena.
|
||||
Everyone Svi
|
||||
Exceptions (optional) Izuzeci (neobavezno)
|
||||
Exceptions… Izuzeci…
|
||||
Fields Polja
|
||||
Filling in forms Popunjavanje obrazaca
|
||||
Find Next Editable Region Pronađi sledeći deo za uređivanje
|
||||
Footnotes and endnotes Fusnote i krajnje napomene
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Porede se oblikovanje, veličina slova, razmaci i tabele; ostala podešavanja se čuvaju za kasnije.
|
||||
Fractions (1/2) as fraction characters (½) Razlomci (1/2) kao znakovi razlomka (½)
|
||||
Headers and footers Zaglavlja i podnožja
|
||||
Hyphens (--) as dashes (—) Crtice (--) kao crte (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. U jednačinama se naziv otkucan posle obrnute kose crte zamenjuje simbolom.
|
||||
Internet addresses as links Internet adrese kao veze
|
||||
Label changes with: Označi izmene sa:
|
||||
Limit formatting to a selection of styles Ograniči oblikovanje na izabrane stilove
|
||||
Math AutoCorrect Automatska ispravka matematike
|
||||
More >> Više >>
|
||||
Moves Premeštanja
|
||||
New document Novi dokument
|
||||
No changes (read only) Bez izmena (samo za čitanje)
|
||||
Only allow this kind of editing: Dozvoli samo ovu vrstu uređivanja:
|
||||
Only comments can be added, except in regions marked editable. Mogu se dodavati samo komentari, osim u delovima označenim za uređivanje.
|
||||
Only form fields can be filled in. Mogu se popuniti samo polja obrasca.
|
||||
Only the allowed styles can be applied. Mogu se primeniti samo dozvoljeni stilovi.
|
||||
Ordinals (1st) as superscript Redni brojevi (1st) kao eksponent
|
||||
Original document Originalni dokument
|
||||
This password type isn't supported. Ova vrsta lozinke nije podržana.
|
||||
Replace as you type Zameni dok kucaš
|
||||
Replace text as you type Zameni tekst dok kucaš
|
||||
Replace: Zameni:
|
||||
Revised document Izmenjeni dokument
|
||||
Select text, then tick who can still edit it. Izaberite tekst, pa označite ko još može da ga uređuje.
|
||||
Settings… Podešavanja…
|
||||
Show changes at: Prikaži izmene na nivou:
|
||||
Show changes in: Prikaži izmene u:
|
||||
Smart quotes instead of straight quotes Tipografski navodnici umesto pravih
|
||||
Start Enforcing Protection Pokreni zaštitu
|
||||
Stop Protection Zaustavi zaštitu
|
||||
Text boxes Okviri za tekst
|
||||
The document is read only, except for regions marked editable. Dokument je samo za čitanje, osim delova označenih za uređivanje.
|
||||
The password is optional; without it anyone can stop the protection. Lozinka nije obavezna; bez nje svako može da zaustavi zaštitu.
|
||||
This document Ovaj dokument
|
||||
Tracked changes Praćene izmene
|
||||
Type the password to stop the protection. Unesite lozinku da biste zaustavili zaštitu.
|
||||
White space Razmaci
|
||||
With: Sa:
|
||||
Word level Nivo reči
|
||||
In Line with Text U ravni sa tekstom
|
||||
Square Kvadratno
|
||||
Through Kroz
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1364,6 +1364,80 @@
|
|||
Formatted Форматирано
|
||||
Interface size: Величина интерфејса:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно.
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. Ограничења обликовања
|
||||
2. Editing restrictions 2. Ограничења уређивања
|
||||
3. Start enforcement 3. Покретање заштите
|
||||
<< Less << Мање
|
||||
Another file Друга датотека
|
||||
Apply as you type Примени док куцаш
|
||||
AutoCorrect Аутоматска исправка
|
||||
AutoFormat As You Type Аутоматско обликовање током куцања
|
||||
Automatic bulleted lists Аутоматске листе са знаковима
|
||||
Automatic numbered lists Аутоматске нумерисане листе
|
||||
Border lines Линије ивица
|
||||
Capitalize first letter of sentences Велико прво слово реченице
|
||||
Capitalize first letter of table cells Велико прво слово у ћелијама табеле
|
||||
Capitalize names of days Велико слово у називима дана
|
||||
Case changes Промене величине слова
|
||||
Character level Ниво знака
|
||||
Checked styles are allowed: Означени стилови су дозвољени:
|
||||
Combine Documents Спајање докумената
|
||||
Compare Documents Упоређивање докумената
|
||||
Compared: {changes} changes Упоређено: измена {changes}
|
||||
Comparing with another file needs the desktop app. За поређење са другом датотеком потребна је десктоп апликација.
|
||||
Comparison settings Подешавања поређења
|
||||
Don't capitalize after: Без великог слова после:
|
||||
Editing in this document is restricted. Уређивање овог документа је ограничено.
|
||||
Every change is recorded as a tracked change. Свака измена се бележи као праћена измена.
|
||||
Everyone Сви
|
||||
Exceptions (optional) Изузеци (необавезно)
|
||||
Exceptions… Изузеци…
|
||||
Fields Поља
|
||||
Filling in forms Попуњавање образаца
|
||||
Find Next Editable Region Пронађи следећи део за уређивање
|
||||
Footnotes and endnotes Фусноте и крајње напомене
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Пореде се обликовање, величина слова, размаци и табеле; остала подешавања се чувају за касније.
|
||||
Fractions (1/2) as fraction characters (½) Разломци (1/2) као знакови разломка (½)
|
||||
Headers and footers Заглавља и подножја
|
||||
Hyphens (--) as dashes (—) Цртице (--) као црте (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. У једначинама се назив откуцан после обрнуте косе црте замењује симболом.
|
||||
Internet addresses as links Интернет адресе као везе
|
||||
Label changes with: Означи измене са:
|
||||
Limit formatting to a selection of styles Ограничи обликовање на изабране стилове
|
||||
Math AutoCorrect Аутоматска исправка математике
|
||||
More >> Више >>
|
||||
Moves Премештања
|
||||
New document Нови документ
|
||||
No changes (read only) Без измена (само за читање)
|
||||
Only allow this kind of editing: Дозволи само ову врсту уређивања:
|
||||
Only comments can be added, except in regions marked editable. Могу се додавати само коментари, осим у деловима означеним за уређивање.
|
||||
Only form fields can be filled in. Могу се попунити само поља обрасца.
|
||||
Only the allowed styles can be applied. Могу се применити само дозвољени стилови.
|
||||
Ordinals (1st) as superscript Редни бројеви (1st) као експонент
|
||||
Original document Оригинални документ
|
||||
This password type isn't supported. Ова врста лозинке није подржана.
|
||||
Replace as you type Замени док куцаш
|
||||
Replace text as you type Замени текст док куцаш
|
||||
Replace: Замени:
|
||||
Revised document Измењени документ
|
||||
Select text, then tick who can still edit it. Изаберите текст, па означите ко још може да га уређује.
|
||||
Settings… Подешавања…
|
||||
Show changes at: Прикажи измене на нивоу:
|
||||
Show changes in: Прикажи измене у:
|
||||
Smart quotes instead of straight quotes Типографски наводници уместо правих
|
||||
Start Enforcing Protection Покрени заштиту
|
||||
Stop Protection Заустави заштиту
|
||||
Text boxes Оквири за текст
|
||||
The document is read only, except for regions marked editable. Документ је само за читање, осим делова означених за уређивање.
|
||||
The password is optional; without it anyone can stop the protection. Лозинка није обавезна; без ње свако може да заустави заштиту.
|
||||
This document Овај документ
|
||||
Tracked changes Праћене измене
|
||||
Type the password to stop the protection. Унесите лозинку да бисте зауставили заштиту.
|
||||
White space Размаци
|
||||
With: Са:
|
||||
Word level Ниво речи
|
||||
In Line with Text У равни са текстом
|
||||
Square Квадратно
|
||||
Through Кроз
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
|
|
@ -1597,6 +1597,78 @@
|
|||
Search icons Simgelerde ara
|
||||
No icons match your search. Aramanızla eşleşen simge yok.
|
||||
Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir
|
||||
1. Formatting restrictions 1. Biçimlendirme kısıtlamaları
|
||||
2. Editing restrictions 2. Düzenleme kısıtlamaları
|
||||
3. Start enforcement 3. Korumayı başlat
|
||||
<< Less << Daha az
|
||||
Another file Başka bir dosya
|
||||
Apply as you type Yazarken uygula
|
||||
AutoCorrect Otomatik Düzelt
|
||||
AutoFormat As You Type Yazarken Otomatik Biçimlendir
|
||||
Automatic bulleted lists Otomatik madde işaretli listeler
|
||||
Automatic numbered lists Otomatik numaralı listeler
|
||||
Border lines Kenarlık çizgileri
|
||||
Capitalize first letter of sentences Cümlelerin ilk harfini büyük yap
|
||||
Capitalize first letter of table cells Tablo hücrelerinin ilk harfini büyük yap
|
||||
Capitalize names of days Gün adlarını büyük harfle başlat
|
||||
Case changes Büyük/küçük harf değişiklikleri
|
||||
Character level Karakter düzeyi
|
||||
Checked styles are allowed: İşaretli stillere izin verilir:
|
||||
Combine Documents Belgeleri Birleştir
|
||||
Compare Documents Belgeleri Karşılaştır
|
||||
Compared: {changes} changes Karşılaştırıldı: {changes} değişiklik
|
||||
Comparing with another file needs the desktop app. Başka bir dosyayla karşılaştırma için masaüstü uygulaması gerekir.
|
||||
Comparison settings Karşılaştırma ayarları
|
||||
Don't capitalize after: Şundan sonra büyük harf yapma:
|
||||
Editing in this document is restricted. Bu belgede düzenleme kısıtlanmıştır.
|
||||
Every change is recorded as a tracked change. Her değişiklik izlenen değişiklik olarak kaydedilir.
|
||||
Everyone Herkes
|
||||
Exceptions (optional) Özel durumlar (isteğe bağlı)
|
||||
Exceptions… Özel durumlar…
|
||||
Fields Alanlar
|
||||
Filling in forms Form doldurma
|
||||
Find Next Editable Region Sonraki Düzenlenebilir Bölgeyi Bul
|
||||
Footnotes and endnotes Dipnotlar ve sonnotlar
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Biçimlendirme, harf büyüklüğü, boşluklar ve tablolar karşılaştırılır; diğer ayarlar daha sonra için saklanır.
|
||||
Fractions (1/2) as fraction characters (½) Kesirleri (1/2) kesir karakteri (½) olarak yaz
|
||||
Headers and footers Üst bilgiler ve alt bilgiler
|
||||
Hyphens (--) as dashes (—) Kısa çizgileri (--) uzun çizgi (—) olarak yaz
|
||||
In equations, typing a name after a backslash replaces it with its symbol. Denklemlerde ters eğik çizgiden sonra yazılan ad, simgesiyle değiştirilir.
|
||||
Internet addresses as links İnternet adreslerini bağlantı yap
|
||||
Label changes with: Değişiklikleri şununla etiketle:
|
||||
Limit formatting to a selection of styles Biçimlendirmeyi seçili stillerle sınırla
|
||||
Math AutoCorrect Matematik Otomatik Düzelt
|
||||
More >> Daha fazla >>
|
||||
Moves Taşımalar
|
||||
New document Yeni belge
|
||||
No changes (read only) Değişiklik yok (salt okunur)
|
||||
Only allow this kind of editing: Yalnızca bu tür düzenlemeye izin ver:
|
||||
Only comments can be added, except in regions marked editable. Düzenlenebilir işaretli bölgeler dışında yalnızca yorum eklenebilir.
|
||||
Only form fields can be filled in. Yalnızca form alanları doldurulabilir.
|
||||
Only the allowed styles can be applied. Yalnızca izin verilen stiller uygulanabilir.
|
||||
Ordinals (1st) as superscript Sıra sayılarını (1st) üst simge yap
|
||||
Original document Özgün belge
|
||||
This password type isn't supported. Bu parola türü desteklenmiyor.
|
||||
Replace as you type Yazarken değiştir
|
||||
Replace text as you type Yazarken metni değiştir
|
||||
Replace: Değiştirilecek:
|
||||
Revised document Düzeltilmiş belge
|
||||
Select text, then tick who can still edit it. Metni seçin, sonra kimlerin düzenleyebileceğini işaretleyin.
|
||||
Settings… Ayarlar…
|
||||
Show changes at: Değişiklikleri göster:
|
||||
Show changes in: Değişiklikleri şurada göster:
|
||||
Smart quotes instead of straight quotes Düz tırnak yerine akıllı tırnak
|
||||
Start Enforcing Protection Korumayı Başlat
|
||||
Stop Protection Korumayı Durdur
|
||||
Text boxes Metin kutuları
|
||||
The document is read only, except for regions marked editable. Düzenlenebilir işaretli bölgeler dışında belge salt okunurdur.
|
||||
The password is optional; without it anyone can stop the protection. Parola isteğe bağlıdır; olmadan herkes korumayı durdurabilir.
|
||||
This document Bu belge
|
||||
Tracked changes İzlenen değişiklikler
|
||||
Type the password to stop the protection. Korumayı durdurmak için parolayı yazın.
|
||||
White space Boşluk
|
||||
With: Yeni:
|
||||
Word level Sözcük düzeyi
|
||||
Change Chart Type Grafik Türünü Değiştir
|
||||
Chart Design Grafik Tasarımı
|
||||
Type Tür
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 18.
|
|
|
@ -1362,6 +1362,80 @@
|
|||
Formatted Відформатовано
|
||||
Interface size: Розмір інтерфейсу:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо.
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. Обмеження форматування
|
||||
2. Editing restrictions 2. Обмеження редагування
|
||||
3. Start enforcement 3. Увімкнення захисту
|
||||
<< Less << Менше
|
||||
Another file Інший файл
|
||||
Apply as you type Застосовувати під час введення
|
||||
AutoCorrect Автовиправлення
|
||||
AutoFormat As You Type Автоформат під час введення
|
||||
Automatic bulleted lists Автоматичні марковані списки
|
||||
Automatic numbered lists Автоматичні нумеровані списки
|
||||
Border lines Лінії меж
|
||||
Capitalize first letter of sentences Перша літера речення велика
|
||||
Capitalize first letter of table cells Перша літера в клітинках таблиці велика
|
||||
Capitalize names of days Назви днів із великої літери
|
||||
Case changes Зміни регістру
|
||||
Character level Рівень символів
|
||||
Checked styles are allowed: Позначені стилі дозволено:
|
||||
Combine Documents Об'єднання документів
|
||||
Compare Documents Порівняння документів
|
||||
Compared: {changes} changes Порівняно: змін {changes}
|
||||
Comparing with another file needs the desktop app. Для порівняння з іншим файлом потрібна настільна програма.
|
||||
Comparison settings Параметри порівняння
|
||||
Don't capitalize after: Не робити великої літери після:
|
||||
Editing in this document is restricted. Редагування цього документа обмежено.
|
||||
Every change is recorded as a tracked change. Кожна зміна записується як відстежувана.
|
||||
Everyone Усі
|
||||
Exceptions (optional) Винятки (необов'язково)
|
||||
Exceptions… Винятки…
|
||||
Fields Поля
|
||||
Filling in forms Заповнення форм
|
||||
Find Next Editable Region Знайти наступну область для редагування
|
||||
Footnotes and endnotes Виноски та кінцеві виноски
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. Порівнюються форматування, регістр, пробіли й таблиці; інші параметри збережено на майбутнє.
|
||||
Fractions (1/2) as fraction characters (½) Дроби (1/2) як символи дробів (½)
|
||||
Headers and footers Колонтитули
|
||||
Hyphens (--) as dashes (—) Дефіси (--) як тире (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. У рівняннях назва після зворотної скісної риски замінюється символом.
|
||||
Internet addresses as links Інтернет-адреси як посилання
|
||||
Label changes with: Позначати зміни іменем:
|
||||
Limit formatting to a selection of styles Обмежити форматування вибраними стилями
|
||||
Math AutoCorrect Математичне автовиправлення
|
||||
More >> Більше >>
|
||||
Moves Переміщення
|
||||
New document Новий документ
|
||||
No changes (read only) Без змін (лише читання)
|
||||
Only allow this kind of editing: Дозволити лише такий тип редагування:
|
||||
Only comments can be added, except in regions marked editable. Можна лише додавати коментарі, крім областей, позначених для редагування.
|
||||
Only form fields can be filled in. Можна заповнювати лише поля форми.
|
||||
Only the allowed styles can be applied. Можна застосовувати лише дозволені стилі.
|
||||
Ordinals (1st) as superscript Порядкові (1st) як надрядкові
|
||||
Original document Вихідний документ
|
||||
This password type isn't supported. Цей тип пароля не підтримується.
|
||||
Replace as you type Замінювати під час введення
|
||||
Replace text as you type Замінювати текст під час введення
|
||||
Replace: Замінити:
|
||||
Revised document Змінений документ
|
||||
Select text, then tick who can still edit it. Виділіть текст і позначте, хто може його редагувати.
|
||||
Settings… Параметри…
|
||||
Show changes at: Показувати зміни на рівні:
|
||||
Show changes in: Показувати зміни в:
|
||||
Smart quotes instead of straight quotes Типографські лапки замість прямих
|
||||
Start Enforcing Protection Увімкнути захист
|
||||
Stop Protection Вимкнути захист
|
||||
Text boxes Текстові поля
|
||||
The document is read only, except for regions marked editable. Документ лише для читання, крім областей, позначених для редагування.
|
||||
The password is optional; without it anyone can stop the protection. Пароль необов'язковий; без нього будь-хто може вимкнути захист.
|
||||
This document Цей документ
|
||||
Tracked changes Відстежувані зміни
|
||||
Type the password to stop the protection. Введіть пароль, щоб вимкнути захист.
|
||||
White space Пробіли
|
||||
With: На:
|
||||
Word level Рівень слів
|
||||
In Line with Text У рядку з текстом
|
||||
Square Квадрат
|
||||
Through Наскрізне
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -1362,6 +1362,80 @@
|
|||
Formatted 已设置格式
|
||||
Interface size: 界面大小:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. 格式限制
|
||||
2. Editing restrictions 2. 编辑限制
|
||||
3. Start enforcement 3. 启动保护
|
||||
<< Less << 更少
|
||||
Another file 其他文件
|
||||
Apply as you type 键入时应用
|
||||
AutoCorrect 自动更正
|
||||
AutoFormat As You Type 键入时自动套用格式
|
||||
Automatic bulleted lists 自动项目符号列表
|
||||
Automatic numbered lists 自动编号列表
|
||||
Border lines 边框线
|
||||
Capitalize first letter of sentences 句首字母大写
|
||||
Capitalize first letter of table cells 表格单元格首字母大写
|
||||
Capitalize names of days 星期名称首字母大写
|
||||
Case changes 大小写更改
|
||||
Character level 字符级
|
||||
Checked styles are allowed: 选中的样式允许使用:
|
||||
Combine Documents 合并文档
|
||||
Compare Documents 比较文档
|
||||
Compared: {changes} changes 已比较: {changes} 处更改
|
||||
Comparing with another file needs the desktop app. 与其他文件比较需要桌面版应用。
|
||||
Comparison settings 比较设置
|
||||
Don't capitalize after: 不在以下内容后大写:
|
||||
Editing in this document is restricted. 此文档的编辑受到限制。
|
||||
Every change is recorded as a tracked change. 每处更改都会作为修订记录。
|
||||
Everyone 每个人
|
||||
Exceptions (optional) 例外项(可选)
|
||||
Exceptions… 例外项…
|
||||
Fields 域
|
||||
Filling in forms 填写窗体
|
||||
Find Next Editable Region 查找下一个可编辑区域
|
||||
Footnotes and endnotes 脚注和尾注
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. 会比较格式、大小写、空白和表格;其他设置留待以后支持。
|
||||
Fractions (1/2) as fraction characters (½) 分数 (1/2) 替换为分数字符 (½)
|
||||
Headers and footers 页眉和页脚
|
||||
Hyphens (--) as dashes (—) 连字符 (--) 替换为破折号 (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. 在公式中,在反斜杠后键入名称会将其替换为相应符号。
|
||||
Internet addresses as links Internet 地址替换为链接
|
||||
Label changes with: 修订者标记:
|
||||
Limit formatting to a selection of styles 将格式限制为所选样式
|
||||
Math AutoCorrect 数学符号自动更正
|
||||
More >> 更多 >>
|
||||
Moves 移动
|
||||
New document 新文档
|
||||
No changes (read only) 不允许任何更改(只读)
|
||||
Only allow this kind of editing: 仅允许此类编辑:
|
||||
Only comments can be added, except in regions marked editable. 只能添加批注,标记为可编辑的区域除外。
|
||||
Only form fields can be filled in. 只能填写窗体域。
|
||||
Only the allowed styles can be applied. 只能应用允许的样式。
|
||||
Ordinals (1st) as superscript 序数 (1st) 替换为上标
|
||||
Original document 原文档
|
||||
This password type isn't supported. 不支持此密码类型。
|
||||
Replace as you type 键入时自动替换
|
||||
Replace text as you type 键入时自动替换文本
|
||||
Replace: 替换:
|
||||
Revised document 修订的文档
|
||||
Select text, then tick who can still edit it. 选择文本,然后勾选仍可编辑它的人。
|
||||
Settings… 设置…
|
||||
Show changes at: 显示修订的级别:
|
||||
Show changes in: 修订的显示位置:
|
||||
Smart quotes instead of straight quotes 以弯引号替换直引号
|
||||
Start Enforcing Protection 启动强制保护
|
||||
Stop Protection 停止保护
|
||||
Text boxes 文本框
|
||||
The document is read only, except for regions marked editable. 文档为只读,标记为可编辑的区域除外。
|
||||
The password is optional; without it anyone can stop the protection. 密码是可选的;没有密码,任何人都可以停止保护。
|
||||
This document 此文档
|
||||
Tracked changes 修订
|
||||
Type the password to stop the protection. 键入密码以停止保护。
|
||||
White space 空白
|
||||
With: 替换为:
|
||||
Word level 字词级
|
||||
In Line with Text 与文字同行
|
||||
Square 四周型
|
||||
Through 穿越型
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -1351,6 +1351,80 @@
|
|||
Formatted 已設定格式
|
||||
Interface size: 介面大小:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。
|
||||
|
||||
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
|
||||
1. Formatting restrictions 1. 格式設定限制
|
||||
2. Editing restrictions 2. 編輯限制
|
||||
3. Start enforcement 3. 開始保護
|
||||
<< Less << 較少
|
||||
Another file 其他檔案
|
||||
Apply as you type 輸入時套用
|
||||
AutoCorrect 自動校正
|
||||
AutoFormat As You Type 輸入時自動格式設定
|
||||
Automatic bulleted lists 自動項目符號清單
|
||||
Automatic numbered lists 自動編號清單
|
||||
Border lines 框線
|
||||
Capitalize first letter of sentences 句首字母大寫
|
||||
Capitalize first letter of table cells 表格儲存格首字母大寫
|
||||
Capitalize names of days 星期名稱首字母大寫
|
||||
Case changes 大小寫變更
|
||||
Character level 字元層級
|
||||
Checked styles are allowed: 勾選的樣式可以使用:
|
||||
Combine Documents 合併文件
|
||||
Compare Documents 比較文件
|
||||
Compared: {changes} changes 已比較: {changes} 處變更
|
||||
Comparing with another file needs the desktop app. 與其他檔案比較需要桌面版應用程式。
|
||||
Comparison settings 比較設定
|
||||
Don't capitalize after: 不在以下內容後大寫:
|
||||
Editing in this document is restricted. 此文件的編輯受到限制。
|
||||
Every change is recorded as a tracked change. 每項變更都會記錄為追蹤修訂。
|
||||
Everyone 所有人
|
||||
Exceptions (optional) 例外狀況 (選用)
|
||||
Exceptions… 例外狀況…
|
||||
Fields 功能變數
|
||||
Filling in forms 填寫表單
|
||||
Find Next Editable Region 尋找下一個可編輯區域
|
||||
Footnotes and endnotes 註腳和章節附註
|
||||
Formatting, case changes, white space and tables are compared; the other settings are kept for later. 會比較格式、大小寫、空白和表格;其他設定留待日後支援。
|
||||
Fractions (1/2) as fraction characters (½) 分數 (1/2) 取代為分數字元 (½)
|
||||
Headers and footers 頁首及頁尾
|
||||
Hyphens (--) as dashes (—) 連字號 (--) 取代為破折號 (—)
|
||||
In equations, typing a name after a backslash replaces it with its symbol. 在方程式中,於反斜線後輸入名稱會取代為對應符號。
|
||||
Internet addresses as links 網際網路位址取代為連結
|
||||
Label changes with: 變更標記為:
|
||||
Limit formatting to a selection of styles 限制格式為選取的樣式
|
||||
Math AutoCorrect 數學自動校正
|
||||
More >> 更多 >>
|
||||
Moves 移動
|
||||
New document 新文件
|
||||
No changes (read only) 不允許任何變更 (唯讀)
|
||||
Only allow this kind of editing: 僅允許此類編輯:
|
||||
Only comments can be added, except in regions marked editable. 只能新增註解,標示為可編輯的區域除外。
|
||||
Only form fields can be filled in. 只能填寫表單欄位。
|
||||
Only the allowed styles can be applied. 只能套用允許的樣式。
|
||||
Ordinals (1st) as superscript 序數 (1st) 取代為上標
|
||||
Original document 原始文件
|
||||
This password type isn't supported. 不支援此密碼類型。
|
||||
Replace as you type 輸入時自動取代
|
||||
Replace text as you type 輸入時自動取代文字
|
||||
Replace: 取代:
|
||||
Revised document 修訂的文件
|
||||
Select text, then tick who can still edit it. 選取文字,然後勾選仍可編輯的人。
|
||||
Settings… 設定…
|
||||
Show changes at: 顯示變更的層級:
|
||||
Show changes in: 顯示變更於:
|
||||
Smart quotes instead of straight quotes 以彎引號取代直引號
|
||||
Start Enforcing Protection 開始強制保護
|
||||
Stop Protection 停止保護
|
||||
Text boxes 文字方塊
|
||||
The document is read only, except for regions marked editable. 文件為唯讀,標示為可編輯的區域除外。
|
||||
The password is optional; without it anyone can stop the protection. 密碼為選用;沒有密碼,任何人都能停止保護。
|
||||
This document 此文件
|
||||
Tracked changes 追蹤修訂
|
||||
Type the password to stop the protection. 輸入密碼以停止保護。
|
||||
White space 空白
|
||||
With: 取代為:
|
||||
Word level 字組層級
|
||||
In Line with Text 與文字同行
|
||||
Square 矩形
|
||||
Through 穿透
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -21,8 +21,8 @@ pub mod content_controls;
|
|||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_controls;
|
||||
pub mod dialogs_chart;
|
||||
pub mod dialogs_controls;
|
||||
pub mod dialogs_insert;
|
||||
pub mod dialogs_keyboard;
|
||||
pub mod dialogs_layout;
|
||||
|
|
@ -30,6 +30,7 @@ pub mod dialogs_lists;
|
|||
pub mod dialogs_notes;
|
||||
pub mod dialogs_para;
|
||||
pub mod dialogs_refs;
|
||||
pub mod dialogs_review;
|
||||
pub mod dialogs_ribbon;
|
||||
pub mod equation_tab;
|
||||
pub mod file_dialogs;
|
||||
|
|
@ -191,6 +192,8 @@ pub struct WordApp {
|
|||
/// Content control frames, check boxes and lists on the page.
|
||||
pub controls: content_controls::ControlUi,
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
/// The Restrict Editing pane (#412).
|
||||
pub restrict: dialogs_review::RestrictPane,
|
||||
pub status_msg: Option<(String, f64)>,
|
||||
pub previews: previews::Previews,
|
||||
/// Media key of the picture a pending Change Picture replaces (#147).
|
||||
|
|
@ -274,6 +277,7 @@ impl WordApp {
|
|||
canvas: canvas::CanvasState::default(),
|
||||
controls: Default::default(),
|
||||
dialog: None,
|
||||
restrict: Default::default(),
|
||||
status_msg: None,
|
||||
previews: previews::Previews::default(),
|
||||
integrated_titlebar: false,
|
||||
|
|
@ -1216,6 +1220,12 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
|
|||
// Define New Multilevel List and Track Changes Options without settings show their dialogs.
|
||||
"list.define" if params.get("levels").is_none() => Some("defineList"),
|
||||
"review.trackingOptions" if params.as_object().is_none_or(|m| m.is_empty()) => Some("trackChangesOptions"),
|
||||
// Compare / Combine without a revised document, AutoCorrect Options without settings and
|
||||
// Restrict Editing without settings show their dialogs or pane (#412).
|
||||
"review.compare" if !has("path") && !has("text") => Some("compare"),
|
||||
"review.combine" if !has("path") && !has("text") => Some("combine"),
|
||||
"tools.autocorrect" if params.as_object().is_none_or(|m| m.is_empty()) => Some("autoCorrect"),
|
||||
"review.restrict" | "file.protect" if params.as_object().is_none_or(|m| m.is_empty()) => Some("restrictEditing"),
|
||||
"insert.bookmark" if !has("name") => Some("bookmark"),
|
||||
// Word Count from the keyboard or command search shows its dialog, as the ribbon does.
|
||||
"review.wordCount" if params.as_object().is_none_or(|m| m.is_empty()) => Some("wordCount"),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector,
|
||||
//! Accessibility and Alt Text (see `a11y_pane`), Comments.
|
||||
//! Accessibility and Alt Text (see `a11y_pane`), Restrict Editing ([`crate::dialogs_review`]), Comments.
|
||||
|
||||
use egui::{Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -52,6 +52,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| crate::a11y_pane::alt_text(app, ui));
|
||||
}
|
||||
if app.restrict.open {
|
||||
egui::Panel::right("restrict_pane")
|
||||
.default_size(270.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| {
|
||||
if header(ui, "Restrict Editing") {
|
||||
app.restrict.open = false;
|
||||
return;
|
||||
}
|
||||
crate::dialogs_review::restrict_pane(app, ui);
|
||||
});
|
||||
}
|
||||
if app.session.view.comments_pane {
|
||||
egui::Panel::right("comments_pane")
|
||||
.default_size(290.0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (WordArt effects, text warps and connectors, #416; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Restrict Editing: Word's legacy protection hash verified, unsupported forms refused, #412; previously trivial: Restrict Editing protection, locked styles and exception ranges read and written, #412; import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
|
|
@ -54,6 +54,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
| Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 |
|
||||
| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them |
|
||||
| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. RTF/ODT/PDF export the result (moved-away text hidden like deletions) |
|
||||
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges |
|
||||
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297) |
|
||||
| Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC |
|
||||
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
|
||||
|
|
@ -89,6 +90,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) |
|
||||
| 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) |
|
||||
| 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) |
|
||||
| 2026-10-11 | trivial | Content controls read and written with their properties and nesting (#381) |
|
||||
| 2026-10-11 | trivial | Charts made in WordCraft written as chart parts and read back as editable (#409) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (#10: WordArt and connectors landed, #416; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Compare, AutoCorrect Options and Restrict Editing landed, #412; #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
|
|
@ -17,7 +17,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
|||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 7 | **Dialog depth**: 46 modal dialogs vs Word's ~100 (Compare, Protect, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Dialog depth**: 52 modal dialogs vs Word's ~100 (Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
|
||||
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
|
||||
|
|
@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Compare / Combine Documents and AutoCorrect Options dialogs and the Restrict Editing pane landed (#412); removed from the dialog-depth list; duplicate #7 rows merged |
|
||||
| 2026-10-11 | trivial | #10: WordArt text effects and Transform warps, connectors glued to shapes (#416) |
|
||||
| 2026-10-11 | trivial | #5 narrowed: content controls kept, shown and edited (#381); move tracking remains |
|
||||
| 2026-10-11 | trivial | Insert › Chart landed (#409); gap #13 narrowed to editing Word's charts, more types and SmartArt, 35–55 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WordCraft parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (WordArt and connectors landed, #416; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Compare settings and Restrict Editing pane, #412; English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are
|
||||
|
|
@ -65,7 +65,7 @@ full parity for the area.
|
|||
| Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. |
|
||||
| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
|
||||
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
|
||||
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. |
|
||||
|
|
@ -251,6 +251,7 @@ can be used without committing Word output.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Compare settings, Restrict Editing pane and AutoCorrect Options (#412): Review row updated; no percentage change |
|
||||
| 2026-10-11 | trivial | Pictures, shapes, text boxes, WordArt: WordArt text effects and Transform warps, connectors glued to shapes (#416); catalog 423/448 → 428/453 (94.5%); no percentage change |
|
||||
| 2026-10-11 | trivial | Content controls kept, shown and edited, Developer › Controls (#381); catalog +11 rows, all live (402/434 → 413/445, 92.8%); no percentage change |
|
||||
| 2026-10-11 | trivial | Insert › Chart (#409): charts row updated, catalog 408/439; no percentage change |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Typing parity with Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (names of days capitalised; AutoCorrect Options switch each AutoCorrect and AutoFormat behaviour, #412; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
What Word does when you type, observed black-box (same keystrokes typed into Word and
|
||||
WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
|
|
@ -60,6 +60,12 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin
|
|||
`typing_at_either_end_of_a_link_is_not_linked`).
|
||||
- Enter after `---` `===` `***` `___` `~~~` `###` → a bottom border on the paragraph above
|
||||
(single, double, dotted, thick, wave, triple) (`border_line_autoformat`).
|
||||
- Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412,
|
||||
`tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and
|
||||
table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and
|
||||
numbered lists, border lines; user entries are added, replaced or deleted (built-in ones too),
|
||||
and "don't capitalize after" exceptions extend the abbreviation list. A replacement of several
|
||||
words takes a sentence capital on its first (`autocorrect_options_change_what_typing_does`).
|
||||
|
||||
## Layout
|
||||
|
||||
|
|
@ -91,6 +97,7 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Names of days capitalised; AutoCorrect Options (user entries, deleted built-ins, exceptions, every AutoCorrect/AutoFormat switch) change typing (#412) |
|
||||
| 2026-10-11 | minor | Editing keys: Move Paragraph Up/Down, Promote/Demote, Go Back (Shift+F5), Alt+X (#343) |
|
||||
| 2026-10-11 | minor | Hyperlinks: text typed at either end of a link or after Enter stays outside it (#413) |
|
||||
| 2026-10-10 | trivial | Status line, revision history and cross-links added (progress-docs standard) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Compare / Combine and AutoCorrect Options dialogs, Restrict Editing pane, #412; Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
|
|
@ -16,8 +16,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 |
|
||||
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
|
||||
| Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Modal dialogs | **49** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Compare, Protect, AutoCorrect, Options panes…) | ~45% | 11–19 |
|
||||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
| Modal dialogs | **52** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 |
|
||||
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 |
|
||||
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 |
|
||||
|
|
@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Compare / Combine Documents (original and revised document, label changes with, comparison settings, word or character level, show changes in) and AutoCorrect Options (replacement list, capitalization, exceptions, AutoFormat As You Type, Math AutoCorrect) dialogs; Restrict Editing pane (formatting and editing restrictions, exceptions for everyone, password); modal dialog rows merged into one (#412) |
|
||||
| 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one |
|
||||
| 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) |
|
||||
| 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue