Compare commits

..
Author SHA1 Message Date
Martin BallandClaude Sonnet 5.5 4ebfc3c2a7 #3518: whole-document OK sends only what was changed in the Language dialog
With "Apply to the whole document" ticked, OK sends noProof only if the user
changed it from the dialog's opening value, and lang if changed or if neither
changed (never the engine's en-US default). Heading reads "Mark the whole
document as:" while ticked. Glossary walker uses doc's PARA_WALK_DEPTH (now pub).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 16:09:02 +00:00
Martin BallandClaude Sonnet 5.5 c09da43475 #3518: Review › Language can set the whole document's proofing language
review.language takes scope "selection" (default, unchanged) or "document":
every run and paragraph mark of every story (body, headers, footers, notes,
comments, text boxes, table cells, glossary) takes lang/noProof in one undo
step, selection unchanged, pending typing format follows. The Language dialog
gets an "Apply to the whole document" check box.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 15:30:26 +00:00
308 changed files with 1292 additions and 17299 deletions
-8
View File
@@ -18,14 +18,7 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
@@ -38,7 +31,6 @@ jobs:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
Generated
+1 -16
View File
@@ -1158,21 +1158,10 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -6846,16 +6835,13 @@ dependencies = [
name = "wordcraft-cli"
version = "0.15.1"
dependencies = [
"image",
"serde_json",
"sys-locale",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-mcp",
"wordcraft-render",
"wordcraft-zotero",
"zip 3.0.0",
]
[[package]]
@@ -7112,7 +7098,6 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
-18
View File
@@ -48,21 +48,3 @@ _Avoid_: User dictionary (in the interface)
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
comes after it).
_Avoid_: Custom dictionaries
## Export
**Strict export**:
An export that writes nothing, and reports why, when a character or picture the output is meant
to show would be missing from it. Text the output doesn't show (hidden text, deletions without
markup) isn't checked.
_Avoid_: Safe mode, validated export
**Missing glyph**:
A character the output draws that no face in the font fallback chain can draw, so it would appear
as an empty box.
_Avoid_: Tofu, box, notdef character
**Missing picture**:
A picture the document refers to that the output cannot include: its part is absent, its data
cannot be decoded, its format is unsupported, or it is linked from outside the file.
_Avoid_: Broken image, dropped image
-35
View File
@@ -157,7 +157,6 @@ Command line:
```sh
wordcraft-cli convert report.docx report.pdf # docx, pdf, odt, rtf, html, md, tex, txt, png
wordcraft-cli convert report.docx report.pdf --strict # exit 3, no file, if a character or picture would be left out
wordcraft-cli text report.docx # plain text
wordcraft-cli inspect report.docx # structure as JSON
wordcraft-cli run --template sample \
@@ -165,40 +164,6 @@ wordcraft-cli run --template sample \
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
```
`convert --strict` (a `.pdf` or `.png` output only) and `render --strict` (a PNG of one page) refuse a silently incomplete render: they exit 3
and write nothing if the output would draw a character no font has (an empty box) or leave out a
picture. Exit codes are 0 (written), 1 (unreadable input, usage error, or `--strict` with another
output type, checked before the input is read) and 3 (strict refusal). On exit 3, stderr has one
`wordcraft-cli: strict: …` line per kind, then one JSON line:
```sh
wordcraft-cli convert in.docx out.pdf --strict
# wordcraft-cli: strict: 1 character cannot be drawn: U+2D30 (×3)
# wordcraft-cli: strict: 1 picture(s) left out: word/media/image2.bin (header2.xml): unsupported format
# {"schema":1,"strict":"failed","missing_glyphs":[{"cp":"U+2D30","count":3}],"missing_pictures":[{"part":"word/media/image2.bin","source_part":"word/header2.xml","reason":"unsupported_format"}],"truncated":false,"total_missing_glyphs":1,"total_missing_pictures":1,"glyphs_truncated":false,"pictures_truncated":false}
```
The JSON line (schema 1, also what `file.strictReport` returns) has `schema`, `strict`
(`"passed"` or `"failed"`), `missing_glyphs` (entries `{"cp", "count"}`, sorted by code point),
`missing_pictures` (entries `{"part", "source_part", "reason"}`: the picture's part or target, the
story part it sits in such as `word/header2.xml`, and a reason code; `source_part` is also
`background` (the page picture), `body`, `header`, `footer`, `footnote`, `endnote`, `comment` or
`text box` where the part name is not known (a document not opened from a DOCX), or a chunk path such as
`word/afchunk1.docx > word/header1.xml` for a picture inside an embedded DOCX chunk), `truncated`, the two totals
`total_missing_glyphs` and `total_missing_pictures`, and `glyphs_truncated` and
`pictures_truncated` for each list. The reason codes are `missing_part`, `external_target`,
`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. When a list is cut, the stderr line gives the total
(`300 characters cannot be drawn (first 256 listed)`).
Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals
count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text
without `--markup`), and a character a fallback font draws is fine. A Private Use Area character
fails only if no installed font maps it. The result depends on the fonts installed on the host
(see #5624 for a fixed font set). An external picture target is never fetched: it is reported, not
loaded. Pictures dropped when a DOCX is opened (a missing part, an external target, or a `link only` picture with no embedded copy) are reported for DOCX input only. They have no page, so a PNG of any page is also refused for a missing or external picture anywhere in the document, unused headers included. `render --strict` checks only the page it
writes (`--page`; `--scale` doesn't matter), prints nothing on stdout when it refuses, and reports the
same way. The engine takes `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and
`file.exportPng`, and `file.strictReport {markup?, page?}` returns the report without writing.
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
## For agents: CLI and MCP
+3 -132
View File
File diff suppressed because one or more lines are too long
-5
View File
@@ -21,8 +21,3 @@ wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
[dev-dependencies]
wordcraft-fonts = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+60 -189
View File
@@ -17,23 +17,17 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); --update-fields refreshes unlocked fields before saving;
an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt;
--strict (pdf or png output only) writes nothing and exits 3
if a character can't be drawn or a picture is missing or
undecodable; the report is on stderr, with one JSON line
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
wordcraft-cli render <file> <out.png> [--page N] [--scale S] [--strict]
--strict writes nothing and exits 3 if a character on the
page can't be drawn or a picture is missing or undecodable
(report on stderr, as for convert --strict)
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
[--save OUT] [--print] [--author NAME]
run commands headlessly, then save (--author names
@@ -83,14 +77,6 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
}
/// A failure whose message may carry text from a file or from the engine's reading of one (a
/// picture's part name, a parse error quoting the input): control, bidi and invisible characters
/// are escaped, so it can't forge or spoof a line (the CLI's own messages, which have line breaks
/// on purpose, are not routed through here). Backslashes stay: messages quote Windows paths.
fn engine_error(e: impl fmt::Display) -> Failure {
Failure::Error(escape_text(&e.to_string(), false))
}
/// The error every subcommand gives when a required path is not there.
fn missing_argument() -> Failure {
format!("missing argument\n\n{USAGE}").into()
@@ -99,11 +85,11 @@ fn missing_argument() -> Failure {
/// Open a document for a subcommand. A password-protected Word file needs its password
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, Failure> {
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region).map_err(engine_error)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let mut s = session(doc);
s.path = Some(path.into());
if encrypted {
@@ -112,8 +98,8 @@ fn open(path: &Path, password: Option<&str>) -> Result<Session, Failure> {
Ok(s)
}
/// The commands that open a file, and which positional argument it is, excluding option values.
/// `run` opens the value of `--file`.
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
/// `pos` counts). `run` opens the value of `--file`.
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
@@ -122,14 +108,8 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0),
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
let cmd = args.first().map(String::as_str).unwrap_or("");
let specs = match cmd {
"convert" => CONVERT_OPTIONS,
"render" => RENDER_OPTIONS,
"zotero" => ZOTERO_OPTIONS,
_ => PASSWORD_OPTIONS,
};
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
None => None,
};
@@ -165,13 +145,8 @@ fn password_from(args: &[String]) -> Result<Option<String>, String> {
}
/// The positional arguments, skipping every option's value so an option value is never read as a
/// path. Uses the same option specifications as [`validate_options`].
/// path. Mirrors [`validate_options`], which has already checked the options are known.
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect()
}
/// Keep the original indices too, so [`text_args`] can open a positional path by its native name.
fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
let mut out = Vec::new();
let mut i = 0;
while let Some(arg) = args.get(i) {
@@ -181,7 +156,7 @@ fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
i += 2;
} else {
if !arg.starts_with("--") {
out.push(i);
out.push(arg);
}
i += 1;
}
@@ -212,7 +187,7 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
/// The command also checks whether the open document is protected: this name determines which
/// named editing exceptions the session can use, so the flag must not bypass that check.
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| escape_text(&format!("--author: {e}"), false))
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
}
fn arg_value(args: &[String], name: &str) -> Option<String> {
@@ -324,12 +299,10 @@ const CONVERT_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--markup", takes_value: false },
OptionSpec { name: "--format", takes_value: true },
OptionSpec { name: "--update-fields", takes_value: false },
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--password", takes_value: true },
OptionSpec { name: "--password-file", takes_value: true },
];
const RENDER_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--page", takes_value: true },
OptionSpec { name: "--scale", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
@@ -366,9 +339,6 @@ enum Failure {
/// Whoever read stdout closed it (`wordcraft-cli commands --json | head`): there is nobody left
/// to write to, so stop quietly and exit 0 like other command-line tools.
StdoutClosed,
/// `convert --strict` refused: the text for stderr (the report's lines, then its JSON line).
/// Exit status 3.
Strict(String),
}
impl fmt::Display for Failure {
@@ -376,7 +346,6 @@ impl fmt::Display for Failure {
match self {
Failure::Error(e) => f.write_str(e),
Failure::StdoutClosed => f.write_str("stdout closed"),
Failure::Strict(report) => f.write_str(report),
}
}
}
@@ -422,80 +391,11 @@ fn valid_connect_addr(addr: &str) -> bool {
!host.is_empty() && port.parse::<u16>().is_ok()
}
/// A failed `--strict` export: a strict refusal becomes [`Failure::Strict`] carrying what stderr
/// shows (each line of the report as `wordcraft-cli: …`, then its JSON on one line, from
/// `file.strictReport` for `page` of a page image, else the PDF); any other error is an ordinary
/// failure.
fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: Option<u64>) -> Failure {
if !matches!(&e, wordcraft_engine::CmdError::Failed(m) if m.starts_with("strict: ")) {
return engine_error(e);
}
let mut params = json!({"markup": markup});
if let Some(page) = page {
params["page"] = json!(page);
}
Failure::Strict(match s.run("file.strictReport", &params).map_err(|e| e.to_string()) {
Ok(json) => {
let mut text = String::new();
for line in report_lines(&json) {
text += &format!("wordcraft-cli: {}\n", escape_peer(&line));
}
text + &json_line_safe(&json.to_string())
}
Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {}", escape_text(&e, false)),
})
}
/// The report's human lines, from its JSON (the engine's `StrictReport::lines`).
fn report_lines(json: &Value) -> Vec<String> {
wordcraft_engine::strict::lines_from_json(json)
}
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
validate_options("convert", rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let strict = rest.iter().any(|a| a == "--strict");
// Before the input is opened: a wrong output type is a usage error, not a file error.
let to_png = o.to_ascii_lowercase().ends_with(".png");
if strict && !(to_png || o.to_ascii_lowercase().ends_with(".pdf")) {
return Err("convert: --strict needs a .pdf or .png output".into());
}
let password = password_from(rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
}
let r = if to_png {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
} else {
let mut params = json!({"path": o, "markup": markup, "strict": strict});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
if let Err(e) = r {
return Err(if strict { refusal(&mut s, e, markup, to_png.then_some(1)) } else { engine_error(e) });
}
eprintln!("wrote {o}");
Ok(())
}
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
match arg_value(rest, "--script") {
Some(path) if !path.starts_with("--") => {
let source = script_path.unwrap_or(Path::new(&path));
Ok(Some(script::load(source).map_err(engine_error)?))
Ok(Some(script::load(source)?))
}
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
_ => Ok(None),
@@ -527,13 +427,42 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return write_stdout(|o| write!(o, "{USAGE}"));
}
match cmd {
"convert" => run_convert(&rest, open),
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(&rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(&rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let password = password_from(&rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
}
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
let mut params = json!({"path": o, "markup": markup});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
Ok(())
}
"info" => {
validate_options("info", &rest, PASSWORD_OPTIONS)?;
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(engine_error)?).unwrap_or_default();
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{info}"))
}
"text" => {
@@ -549,7 +478,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(engine_error)?).unwrap_or_default();
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{tree}"))
}
"render" => {
@@ -563,17 +492,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
let mut s = open(&i, password.as_deref())?;
let strict = rest.iter().any(|a| a == "--strict");
let mut params = json!({"path": o, "page": page, "scale": scale, "strict": strict});
if strict {
// The check and its report must see the page as it is written.
params["markup"] = json!(false);
}
let r = match s.run("file.exportPng", &params) {
Ok(r) => r,
Err(e) if strict => return Err(refusal(&mut s, e, false, Some(page))),
Err(e) => return Err(engine_error(e)),
};
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
write_stdout(|o| writeln!(o, "{r}"))
}
"run" => {
@@ -592,8 +511,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = session(wordcraft_doc::Document::new());
let first = s.document_id();
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
s.run("file.new", &params).map_err(engine_error)?;
s.close_document(first).map_err(engine_error)?;
s.run("file.new", &params).map_err(|e| e.to_string())?;
s.close_document(first).map_err(|e| e.to_string())?;
s
}
};
@@ -602,7 +521,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
let print = rest.iter().any(|a| a == "--print");
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
let r = s.run(id, params).map_err(|e| engine_error(format!("command {} ({id}): {e}", n + 1)))?;
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -612,12 +531,10 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
if rest.get(i).map(String::as_str) == Some("--cmd") {
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
let (id, params) = match spec.split_once('=') {
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
None => (spec.clone(), json!({})),
};
let r = s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
let r = s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -637,7 +554,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return Err(format!("{out}: is a folder, not a file").into());
}
if !out.to_ascii_lowercase().ends_with(".png") {
wordcraft_engine::io::save_bytes(out, &s.doc).map_err(engine_error)?;
wordcraft_engine::io::save_bytes(out, &s.doc)?;
}
}
for out in outputs {
@@ -653,7 +570,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
} else {
s.run("file.save", &json!({"path": out}))
};
r.map_err(engine_error)?;
r.map_err(|e| e.to_string())?;
eprintln!("wrote {out}");
}
Ok(())
@@ -752,12 +669,10 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = open(file, None)?;
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
let (id, params) = match spec.split_once('=') {
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
None => (spec.clone(), json!({})),
};
s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
}
let mut opts = wordcraft_zotero::client::Options::default();
if let Some(p) = arg_value(&rest, "--port") {
@@ -774,13 +689,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
r
})
.map_err(engine_error)?;
.map_err(|e| e.to_string())?;
for a in &host.alerts {
eprintln!("Zotero: {}", escape_peer(a));
}
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
if let Some(o) = arg_value(&rest, "--save") {
s.run("file.save", &json!({"path": o})).map_err(engine_error)?;
s.run("file.save", &json!({"path": o})).map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
}
Ok(())
@@ -804,57 +719,17 @@ fn clip(s: &str) -> String {
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
/// terminal or pass for other output.
fn escape_peer(s: &str) -> String {
escape_text(s, true)
}
/// Whether `c` must not reach a terminal as itself: a control character, a line or paragraph
/// separator, a bidi control, or one that shows nothing (soft hyphen, zero-width characters, word
/// joiners, the byte order mark, tag characters), which can hide or reorder what a line says.
fn is_unsafe_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{00ad}'
| '\u{061c}'
| '\u{200b}'..='\u{200f}'
| '\u{2028}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}'
| '\u{e0000}'..='\u{e007f}'
)
}
/// [`escape_peer`], with backslashes escaped only when `backslash` (error messages quote Windows
/// paths, and a backslash can't forge or hide anything).
fn escape_text(s: &str, backslash: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' if backslash => out.push_str("\\\\"),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if is_unsafe_char(c) => out.extend(c.escape_unicode()),
c => out.push(c),
}
}
out
}
/// A JSON line safe to print: serde escapes control characters below U+0020 but not the other
/// characters [`is_unsafe_char`] names, which a crafted file could put in a report. They become
/// `\uXXXX` escapes (a surrogate pair above U+FFFF), so the line parses to the same value and
/// can't drive or spoof a terminal.
fn json_line_safe(json: &str) -> String {
let mut out = String::with_capacity(json.len());
for c in json.chars() {
if is_unsafe_char(c) {
for unit in c.encode_utf16(&mut [0u16; 2]) {
out.push_str(&format!("\\u{unit:04x}"));
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
out.extend(c.escape_unicode())
}
} else {
out.push(c);
c => out.push(c),
}
}
out
@@ -900,10 +775,6 @@ fn main() -> ExitCode {
eprintln!("wordcraft-cli: {e}");
ExitCode::FAILURE
}
Err(Failure::Strict(report)) => {
eprintln!("{report}");
ExitCode::from(3)
}
}
}
-21
View File
@@ -211,10 +211,6 @@ fn only_the_opened_file_may_be_other_than_unicode() {
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
// Every command that opens a file reaches the open with it (no such file here), so
@@ -232,7 +228,6 @@ fn only_the_opened_file_may_be_other_than_unicode() {
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
@@ -564,19 +559,3 @@ fn script_parse_resolves_paths_against_the_base() {
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
#[test]
fn invisible_characters_are_escaped_and_json_lines_still_parse_back() {
let hidden = "a\u{ad}b\u{200b}c\u{200d}d\u{2060}e\u{2064}f\u{feff}g\u{e0041}h\u{e007f}i\u{85}j\u{202e}k";
let shown = escape_peer(hidden);
assert!(!shown.chars().any(is_unsafe_char), "{shown}");
assert!(shown.contains("\\u{e0041}") && shown.contains("\\u{ad}"), "{shown}");
// Errors keep a Windows path readable.
assert_eq!(escape_text("C:\\dir\\a.docx", false), "C:\\dir\\a.docx");
let value = json!({"part": hidden, "plain": "ünïcode ok"});
let line = json_line_safe(&value.to_string());
assert!(!line.chars().any(is_unsafe_char), "{line}");
assert!(line.contains("\\ud800") || line.contains("\\udb40"), "{line}");
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), value);
}
-1
View File
@@ -6,5 +6,4 @@ mod help;
#[cfg(all(unix, not(target_os = "macos")))]
mod non_utf8_args;
mod picture_then_typing;
mod strict;
mod text_tracked;
@@ -53,15 +53,6 @@ fn the_file_a_command_opens_may_have_any_name() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_option_before_the_input_keeps_its_native_name() {
let (dir, file) = folder("option-first");
let saved = dir.join("out.xml");
ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()]));
assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn any_other_such_argument_is_an_error_naming_it() {
let (dir, file) = folder("error");
-396
View File
@@ -1,396 +0,0 @@
//! `convert --strict` (#6005): exit 3 and a report when a PDF or PNG would silently lack a
//! character or a picture, nothing written; exit 0 otherwise.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use wordcraft_doc::Document;
use wordcraft_fonts::FontDb;
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wc-cli-strict-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn convert(input: &Path, output: &Path, strict: bool) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(input).arg(output);
if strict {
cmd.arg("--strict");
}
cmd.output().unwrap()
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
/// The report's JSON line: the last line of stderr.
fn report_json(o: &Output) -> serde_json::Value {
let text = stderr(o);
let last = text.lines().last().unwrap_or_default();
serde_json::from_str(last).unwrap_or_else(|e| panic!("last stderr line is not JSON ({e}): {text}"))
}
fn json_input(d: &Path, text: &str) -> PathBuf {
let path = d.join("in.json");
std::fs::write(&path, serde_json::to_vec(&Document::from_text(text)).unwrap()).unwrap();
path
}
/// Built without craft-fonts, the host's own fonts decide what is drawable: skip, as the layout
/// tests do.
fn without_fonts() -> bool {
let missing = option_env!("CRAFT_FONTS_DIR").is_none_or(str::is_empty);
if missing {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
}
missing
}
/// A code point in unassigned plane 4 that no face of the host covers.
fn uncovered() -> char {
let db = FontDb::global();
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
(0x40000u32..0x4FFF0)
.filter_map(char::from_u32)
.find(|c| !primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none())
.expect("the host has faces covering all of plane 4")
}
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn pic(rid: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A DOCX whose header holds a picture of `header_media` (`word/media/image2.bin`).
fn docx_with_header_picture(header_media: &[u8]) -> Vec<u8> {
let doc = format!(
r#"<w:document {NS}><w:body><w:p><w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
);
let drels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/></Relationships>"#);
let hrels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#);
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
package(
&doc,
&drels,
&[("word/header2.xml", hdr.as_bytes()), ("word/_rels/header2.xml.rels", hrels.as_bytes()), ("word/media/image2.bin", header_media)],
)
}
/// A DOCX whose body picture points outside the file, at `target`.
fn docx_with_external_picture(target: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p></w:body></w:document>"#, pic("rIdX"));
let drels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdX" Type="{REL}/image" Target="{target}" TargetMode="External"/></Relationships>"#
);
package(&doc, &drels, &[])
}
fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<u8> {
let rels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut parts: Vec<(&str, &[u8])> = vec![
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", document.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
];
parts.extend_from_slice(extra);
for (name, bytes) in parts {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn multilingual_names_pass_strict() {
if without_fonts() {
return;
}
let names = "Łukasz Żmijewski\nGheorghe Ștefănescu\nKrejčí\nŞahın Gül\nLlŷr Grŵn\n王小明\n김민수\nสมชาย ใจดี\nराम शर्मा";
let d = dir("names");
let input = json_input(&d, names);
for out in ["names.pdf", "names.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_uncovered_code_point_fails_strict_with_a_report_and_no_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("uncovered");
let input = json_input(&d, &format!("a{c}b{c}{c}"));
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×3)")), "{text}");
let json = report_json(&r);
assert_eq!(json["schema"], 1);
assert_eq!(json["strict"], "failed");
assert_eq!(json["missing_glyphs"][0]["cp"], cp.as_str());
assert_eq!(json["missing_glyphs"][0]["count"], 3);
assert!(!out.exists(), "{} was written", out.display());
eprintln!("{text}");
// Without --strict the same input converts, as before.
let r = convert(&input, &out, false);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
std::fs::remove_file(&out).unwrap();
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_real_png_in_a_header_passes_strict() {
let d = dir("header-png");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(&png())).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_corrupt_header_picture_fails_strict_naming_the_part_and_the_header() {
let d = dir("header-corrupt");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains("word/media/image2.bin (header2.xml): unsupported format"), "{text}");
let json = report_json(&r);
assert_eq!(json["strict"], "failed");
assert_eq!(
json["missing_pictures"][0],
serde_json::json!({"part": "word/media/image2.bin", "source_part": "word/header2.xml", "reason": "unsupported_format"})
);
assert!(!out.exists());
eprintln!("{text}");
}
// Without --strict the grey box is drawn, as before.
let out = d.join("lenient.pdf");
assert_eq!(convert(&input, &out, false).status.code(), Some(0));
assert!(out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_external_picture_target_is_never_fetched_and_is_reported_under_strict() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let target = format!("http://{}/canary.png", listener.local_addr().unwrap());
let d = dir("external");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture(&target)).unwrap();
let lenient = convert(&input, &d.join("lenient.pdf"), false);
assert_eq!(lenient.status.code(), Some(0), "{}", stderr(&lenient));
let out = d.join("strict.pdf");
let strict = convert(&input, &out, true);
assert_eq!(strict.status.code(), Some(3), "{}", stderr(&strict));
let json = report_json(&strict);
assert_eq!(json["missing_pictures"][0]["reason"], "external_target");
assert!(stderr(&strict).contains("external target"));
assert!(!out.exists());
match listener.accept() {
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Ok((_, from)) => panic!("the converter connected to the external target from {from}"),
Err(e) => panic!("{e}"),
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn bad_input_exits_1_with_the_usual_message_with_and_without_strict() {
let d = dir("bad-input");
let good = docx_with_header_picture(&png());
let truncated = d.join("truncated.docx");
std::fs::write(&truncated, &good[..good.len() / 2]).unwrap();
let not_zip = d.join("not-zip.docx");
std::fs::write(&not_zip, b"this is plainly not a zip archive").unwrap();
for input in [&truncated, &not_zip] {
let plain = convert(input, &d.join("a.pdf"), false);
let strict = convert(input, &d.join("b.pdf"), true);
assert_eq!(plain.status.code(), Some(1), "{}", stderr(&plain));
assert_eq!(strict.status.code(), Some(1), "{}", stderr(&strict));
assert_eq!(stderr(&plain), stderr(&strict));
assert!(stderr(&plain).starts_with("wordcraft-cli: "), "{}", stderr(&plain));
assert!(!d.join("a.pdf").exists() && !d.join("b.pdf").exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_with_another_output_type_exits_1_before_the_input_is_read() {
let d = dir("wrong-output");
let missing = d.join("does-not-exist.docx");
for out in ["out.docx", "out.txt", "out.odt"] {
let r = convert(&missing, &d.join(out), true);
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert!(text.contains("--strict needs a .pdf or .png output"), "{text}");
assert!(!d.join(out).exists());
}
// Without --strict the same command fails on the missing input.
let r = convert(&missing, &d.join("out.docx"), false);
assert_eq!(r.status.code(), Some(1));
assert!(!stderr(&r).contains("--strict"));
std::fs::remove_dir_all(d).unwrap();
}
fn render(input: &Path, output: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("render").arg(input).arg(output).args(args).output().unwrap()
}
/// Page 1 has `a{c}b`, page 2 plain text.
fn two_page_input(d: &Path, text: &str) -> PathBuf {
let mut doc = Document::from_text(&format!("{text}\nSecond page"));
doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props.page_break_before = Some(true);
let path = d.join("two.json");
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
path
}
#[test]
fn render_strict_refuses_an_uncovered_character_on_the_page_with_no_stdout_or_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("render-glyph");
let input = two_page_input(&d, &format!("a{c}b"));
let out = d.join("p1.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty(), "{}", String::from_utf8_lossy(&r.stdout));
assert!(!out.exists());
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×1)")), "{text}");
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
eprintln!("{text}");
// The character is not on page 2, and --scale does not matter to the check.
let out2 = d.join("p2.png");
let r = render(&input, &out2, &["--page", "2", "--strict", "--scale", "0.5"]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out2.exists());
assert!(String::from_utf8_lossy(&r.stdout).contains("\"path\""));
// And the same page 1 without --strict renders, with the usual stdout.
let out3 = d.join("lenient.png");
let r = render(&input, &out3, &[]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out3.exists());
let json: serde_json::Value = serde_json::from_slice(&r.stdout).unwrap();
assert_eq!(json["path"], out3.to_string_lossy().as_ref());
assert!(json["width"].as_u64().is_some() && json["bytes"].as_u64().is_some());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn render_strict_refuses_a_corrupt_header_picture_and_bad_input_still_exits_1() {
let d = dir("render-pic");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
let out = d.join("out.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty());
assert!(stderr(&r).contains("word/media/image2.bin (header2.xml)"), "{}", stderr(&r));
assert!(!out.exists());
let bad = d.join("bad.docx");
std::fs::write(&bad, b"this is plainly not a zip archive").unwrap();
let (plain, strict) = (render(&bad, &out, &[]), render(&bad, &out, &["--strict"]));
assert_eq!((plain.status.code(), strict.status.code()), (Some(1), Some(1)));
assert_eq!(stderr(&plain), stderr(&strict));
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_picture_target_with_a_newline_and_a_bidi_override_cannot_forge_a_report_line() {
let d = dir("escape");
let input = d.join("in.docx");
// XML character references put a newline and U+202E in the target.
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
let lines: Vec<&str> = text.lines().collect();
// One human line for the picture, then the JSON line.
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].starts_with("wordcraft-cli: strict: 1 picture(s) left out: "), "{text}");
assert!(lines[0].contains("\\nwordcraft-cli: strict: forged\\u{202e}gnp.png"), "{text}");
assert!(!text.contains('\u{202e}'), "the raw bidi override reached the terminal");
// The JSON keeps the raw value, escaped by the JSON encoder.
let json = report_json(&r);
let part = json["missing_pictures"][0]["part"].as_str().unwrap();
assert!(part.contains('\n') && part.contains('\u{202e}'), "{part:?}");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_with_strict_cannot_forge_an_error_line_either() {
let d = dir("run-escape");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x1B;[2J&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let cmd = format!("file.exportPdf={}", serde_json::json!({"path": out, "strict": true}));
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("run").arg("--file").arg(&input).arg("--cmd").arg(cmd).output().unwrap();
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert_eq!(text.lines().count(), 1, "{text}");
assert!(text.contains("\\u{1b}") || text.contains("\\u{1b}[2J"), "{text}");
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
assert!(!out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn usage_errors_keep_their_line_breaks() {
// Errors go to stderr with the usage after a blank line (`missing argument`, `unknown command`).
for args in [&["frobnicate"][..], &["convert"][..]] {
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap();
let text = stderr(&r);
assert_eq!(r.status.code(), Some(1), "{args:?}: {text}");
assert!(text.lines().count() > 3, "{args:?}: {text}");
assert!(text.lines().any(|l| l == "USAGE:"), "{args:?}: {text}");
assert!(!text.contains("\\n"), "{args:?}: {text}");
}
// No arguments at all prints the usage as help, on stdout.
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).output().unwrap();
let all = format!("{}{}", String::from_utf8_lossy(&r.stdout), stderr(&r));
assert!(all.lines().count() > 3 && all.lines().any(|l| l == "USAGE:"), "{all}");
}
+1 -3
View File
@@ -17,9 +17,7 @@ path = "src/main.rs"
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
-61
View File
@@ -1,61 +0,0 @@
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
//! when the app reaches it, as it does for a desktop client that timed out.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
use wordcraft_ui_egui::ControlRequest;
/// The app id in `window.<app>`.
const APP: &str = "wordcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
let (queue, requests) = channel::<ControlRequest>();
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
if queue.send(control).is_err() {
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(wait(reply))
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
requests
}
/// The app's reply, polled about once a frame.
async fn wait(reply: Receiver<Value>) -> Value {
loop {
match reply.try_recv() {
Ok(reply) => return reply,
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
}
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
-2
View File
@@ -20,8 +20,6 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
+1 -2
View File
@@ -76,8 +76,7 @@ pub fn start() {
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let control = crate::api::install(cc.egui_ctx.clone());
let mut app = WordApp::new(session, services).with_control(control);
let mut app = WordApp::new(session, services);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
-139
View File
@@ -1,139 +0,0 @@
//! An AppImage's environment inherited from another program (#5653).
//!
//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where
//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage
//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`,
//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft.
//!
//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables
//! are another program's, and the process starts over without them, before anything reads them.
//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the
//! arguments and the standard streams.)
use std::ffi::OsStr;
use std::io::Write;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::Command;
/// What the AppImage runtime sets; the same list as the documented workaround on the issue.
const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"];
/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not:
/// the update then goes through the package or the plain file this copy really is, and at worst
/// says it can't, where the other answer would overwrite a file that isn't WordCraft's.
fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool {
let (Some(exe), Some(appdir)) = (exe, appdir) else { return false };
let appdir = Path::new(appdir);
if !appdir.is_absolute() {
return false;
}
let Ok(appdir) = appdir.canonicalize() else { return false };
// No image mounts at the root, where every program on the machine would count as inside it.
appdir.parent().is_some() && exe.starts_with(&appdir)
}
/// Starts this process over without another program's AppImage variables; returns only when there
/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts.
pub(crate) fn drop_inherited() -> Option<std::io::Error> {
let appimage = std::env::var_os("APPIMAGE")?;
let exe = std::env::current_exe().ok();
if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) {
return None;
}
// The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics.
let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage));
let mut args = std::env::args_os();
// This file, whatever has happened to its path since it started.
let mut again = Command::new("/proc/self/exe");
if let Some(arg0) = args.next() {
again.arg0(arg0);
}
again.args(args);
for var in RUNTIME_VARS {
again.env_remove(var);
}
Some(again.exec())
}
#[cfg(test)]
mod tests {
use super::runs_from_image;
use std::path::{Path, PathBuf};
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A mount with the executable inside it, by real path (`/tmp` may be a link).
fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
let dir = dir.canonicalize().unwrap();
let exe = dir.join("usr/bin/wordcraft");
(dir, exe)
}
#[test]
fn an_executable_in_the_mount_is_the_image() {
let tmp = Tmp::new("own");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
assert!(runs_from_image(Some(&exe), Some(dir.as_os_str())));
}
#[test]
fn a_link_to_the_mount_is_the_same_mount() {
let tmp = Tmp::new("link");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
let link = tmp.0.join("link");
std::os::unix::fs::symlink(&dir, &link).unwrap();
assert!(runs_from_image(Some(&exe), Some(link.as_os_str())));
}
#[test]
fn an_installed_executable_is_not_in_another_programs_mount() {
let tmp = Tmp::new("other");
let (other, _) = mount(&tmp.0, ".mount_other");
assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str())));
}
/// Paths compare by component: one mount's name can start with another's.
#[test]
fn a_mount_whose_name_starts_the_same_is_another_mount() {
let tmp = Tmp::new("prefix");
let (other, _) = mount(&tmp.0, ".mount_ab");
let (_, exe) = mount(&tmp.0, ".mount_abc");
assert!(!runs_from_image(Some(&exe), Some(other.as_os_str())));
}
#[test]
fn anything_unclear_is_not_the_image() {
let tmp = Tmp::new("unclear");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
// No `APPDIR`, one that's gone (the other program has exited) or empty.
assert!(!runs_from_image(Some(&exe), None));
assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str())));
assert!(!runs_from_image(Some(&exe), Some("".as_ref())));
// The root, and a relative path that happens to lead here.
assert!(!runs_from_image(Some(&exe), Some("/".as_ref())));
assert!(!runs_from_image(Some(&exe), Some(".".as_ref())));
// No executable path at all.
assert!(!runs_from_image(None, Some(dir.as_os_str())));
}
}
+2 -3
View File
@@ -36,15 +36,14 @@ impl AppleEvents {
/// Open the documents and act on the quit requests that arrived since the last frame. A document
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
/// is already open, by showing it; a Word template starts a new document based on it, as Word's
/// default verb for one does (#3433). Quitting closes the window, which asks about every document
/// is already open, by showing it. Quitting closes the window, which asks about every document
/// with unsaved changes.
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
match app.open_from_platform(p, None) {
match app.open_file(p, None) {
// Leave Backstage, as opening from the Open dialog does.
Ok(_) => app.ui.backstage = false,
Err(e) => {
+11 -183
View File
@@ -9,7 +9,6 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
@@ -22,38 +21,6 @@ const MAX_LINE: usize = 1 << 20;
/// A connection that sends no authorised request in this time is closed.
const FIRST_REQUEST: Duration = Duration::from_secs(30);
/// How long a caller waits for the UI to answer a request. A request still queued after this
/// is cancelled and never runs.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// Connections that have not yet sent an authorised request are limited to this many; more are
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
const MAX_UNAUTHENTICATED: usize = 64;
/// Counts one unauthenticated connection until it authenticates or ends.
struct Admission(Option<Arc<AtomicUsize>>);
impl Admission {
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::SeqCst) >= max {
count.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(Self(Some(count.clone())))
}
fn release(&mut self) {
if let Some(c) = self.0.take() {
c.fetch_sub(1, Ordering::SeqCst);
}
}
}
impl Drop for Admission {
fn drop(&mut self) {
self.release();
}
}
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
pub struct KeyFile(PathBuf);
@@ -70,15 +37,6 @@ impl KeyFile {
/// cannot be bound or there is no random key (the control channel is off); the key file is
/// `None` when it could not be written (requests are then refused).
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
}
fn start_with(
port: u16,
ctx: egui::Context,
settings: Option<&Path>,
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -109,21 +67,11 @@ fn start_with(
None
}
};
let rx = match serve(listener, ctx, key) {
Ok(rx) => rx,
Err(e) => {
if let Some(f) = &key_file {
f.remove();
}
log::error!("control server off: cannot start the accept thread ({e})");
return None;
}
};
match &key_file {
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
None => log::info!("control server listening on 127.0.0.1:{port}"),
}
Some((rx, key_file))
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -155,47 +103,16 @@ fn keys_match(given: &str, key: &str) -> bool {
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
}
/// The time and count budgets of a server (small ones in tests).
#[derive(Clone, Copy)]
struct Limits {
first: Duration,
wait: Duration,
max_unauthenticated: usize,
}
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
}
fn serve_with_spawner(
listener: TcpListener,
ctx: egui::Context,
key: String,
limits: Limits,
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> std::io::Result<Receiver<ControlRequest>> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
log::warn!("control server: too many unauthenticated connections, one closed");
continue;
};
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
// A thread that cannot start closes this connection only (the guard releases its slot).
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
log::warn!("control server: no thread for a connection: {e}");
}
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
}
};
spawn(Box::new(accept))?;
Ok(rx)
});
rx
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -258,11 +175,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
// One overall deadline from accept to the first authorised request; after it, none.
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -311,7 +228,6 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
if !authorised {
authorised = true;
admission.release();
reader.get_mut().set_deadline(None);
}
if method == "auth" {
@@ -321,18 +237,12 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
if waiter.cancel() {
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
} else {
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
}
});
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if !reply(r) {
break;
}
@@ -350,7 +260,7 @@ mod tests {
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -602,40 +512,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
let dir = temp_dir("spawn-failure");
let path = wordcraft_control_key::key_path(&dir, 0);
let mut bound = None;
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
bound = Some(listener.local_addr().unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
drop(accept);
Err(std::io::Error::other("injected accept thread spawn failure"))
})
});
assert!(result.is_none(), "a server without its accept thread must not report success");
assert!(!path.exists(), "failed startup must remove its published key");
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_complete_json_request_at_eof_can_reach_the_ui() {
// This is why a bridge write failure before the newline is outcome-unknown.
let (port, rx) = boot();
let mut c = Conn::open(port);
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
c.1.write_all(body.to_string().as_bytes()).unwrap();
c.1.shutdown(std::net::Shutdown::Write).unwrap();
let req = answer(&rx);
assert_eq!(req.params["params"]["text"], "once");
assert_eq!(c.recv()["ok"], true);
assert!(c.closed());
assert!(rx.try_recv().is_err());
}
#[test]
fn a_stale_key_file_is_replaced() {
let dir = temp_dir("stale");
@@ -656,52 +532,4 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
}
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
}
#[test]
fn a_request_that_timed_out_in_the_queue_never_runs() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
// The UI gets to it late: it must not run it.
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!req.start(), "a cancelled request was started");
}
#[test]
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(req.start());
let r = c.recv();
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
}
#[test]
fn unauthenticated_connections_are_limited() {
let (port, rx) = boot_limits(small(5_000, 2));
let mut idle = [Conn::open(port), Conn::open(port)];
// Give the server time to count both.
std::thread::sleep(Duration::from_millis(200));
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
// One authenticates: its slot is free again.
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
std::thread::sleep(Duration::from_millis(200));
let mut again = Conn::open(port);
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
assert!(rx.try_recv().is_err());
}
}
+20 -43
View File
@@ -10,8 +10,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod animations;
#[cfg(target_os = "linux")]
mod appimage_env;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
@@ -69,8 +67,6 @@ impl eframe::App for App {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
let b = self.0.window_builder().clone().with_inner_size(size);
self.0.set_window_builder(b);
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
@@ -299,24 +295,25 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
}
fn save_prefs(app: &WordApp) {
if let Err(e) = try_save_prefs(app) {
log::warn!("preferences: {e}");
}
}
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
/// because preferences are switched off or there is no settings folder.
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
if !prefs_enabled() {
return Ok(false);
return;
}
let Some(p) = prefs_path() else { return Ok(false) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
}
}
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
Ok(true)
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
@@ -351,7 +348,6 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
..Default::default()
}
}
@@ -425,15 +421,14 @@ fn without_launch_args(args: Vec<String>) -> Vec<String> {
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word
/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
let name = path.to_string_lossy();
let opened = wordcraft_engine::io::read_document(path)
.and_then(|bytes| app.open_from_platform(&name, Some(&wordcraft_engine::cmd::insert::base64_encode(&bytes))));
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
match opened {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => {
app.report_open_failure(&name, &e);
@@ -454,22 +449,9 @@ fn main() -> eframe::Result {
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// Linux: before anything reads another program's AppImage variables, or a thread starts (#5653).
#[cfg(target_os = "linux")]
let start_over_failed = appimage_env::drop_inherited();
// Still carrying them, this process must never update itself: the update would replace the
// other program's file.
#[cfg(target_os = "linux")]
let updates_unsafe = start_over_failed.is_some();
#[cfg(not(target_os = "linux"))]
let updates_unsafe = false;
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
#[cfg(target_os = "linux")]
if let Some(e) = start_over_failed {
log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}");
}
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -584,10 +566,6 @@ fn main() -> eframe::Result {
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
// After the preferences, so the reason it gives is in the interface language.
if updates_unsafe {
app.disable_self_update();
}
load_user_dictionary(&mut app);
// Language packs from the suite's font store; an Install click is the person's own.
wordcraft_engine::packs::use_font_store(true);
@@ -627,9 +605,8 @@ fn main() -> eframe::Result {
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
// A Word template named here starts a new document based on it (#3433).
for f in files {
if let Err(e) = app.open_from_platform(&f, None) {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
app.report_open_failure(&f, &e);
}
}
-99
View File
@@ -1,99 +0,0 @@
//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update
//! would take for this copy's file and replace (#5653). The real program, started both ways.
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
const NOTICE: &str = "ignoring the AppImage";
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would
/// start it, and waits up to 30 s for it to exit (with no display it can't open a window).
fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
.env("APPIMAGE", appimage)
.env("APPDIR", appdir)
.env("ARGV0", "other-app")
.env("OWD", "/")
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// A start-over that kept the variables would start over for ever, and end here.
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
/// The notice comes once: the program it started over as no longer sees the variables.
#[test]
fn another_programs_variables_are_dropped_once_and_the_program_carries_on() {
let tmp = Tmp::new("dropped");
let other = tmp.0.join("other-app.AppImage");
std::fs::write(&other, b"another program").unwrap();
let out = app(&["--version".into()], &other, &tmp.0);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}");
assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}");
assert_eq!(std::fs::read(&other).unwrap(), b"another program");
}
/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode.
#[test]
fn starting_over_keeps_the_arguments() {
let tmp = Tmp::new("args");
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}");
}
/// Its own image's variables stay, or the self-update couldn't find the file to replace.
#[test]
fn its_own_images_variables_stay() {
let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap();
let mount = exe.parent().unwrap();
let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
assert!(!stderr.contains(NOTICE), "stderr: {stderr}");
}
-2
View File
@@ -1,5 +1,3 @@
#[cfg(target_os = "linux")]
mod appimage_env;
mod control_arg;
mod help;
#[cfg(unix)]
-10
View File
@@ -14,10 +14,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.SenuraMalaka]
real_name = "Senura Dissanayake"
sync_github_name = true
[people.divyanshu2074]
real_name = "Divyanshu Gupta"
sync_github_name = true
@@ -201,9 +197,3 @@ display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
[people.ashokram-si]
real_name = "Ashok Ram"
[people.benandez]
sync_github_name = true
+10 -19
View File
@@ -7,9 +7,8 @@
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
/// listed name) stays within about the file's own size. Anything else that is wrong is left for
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
/// `cfb` to report.
pub fn check(bytes: &[u8]) -> Result<(), String> {
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
@@ -53,14 +52,12 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
while difat.last() == Some(&FREE) {
difat.pop();
}
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
// sectors than the file has — every FAT sector is a sector of the file, and none of them
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
if listed.len() > num_sectors {
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
if listed.len() > needed {
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
}
let mut seen = std::collections::HashSet::with_capacity(listed.len());
@@ -126,16 +123,10 @@ mod tests {
}
#[test]
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
// sector of the file, so a longer list is one of the two.
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
// Each list is shorter than the file has sectors, which the guard used to accept.
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
assert!(
super::check(&header_difat(10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
);
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
-3
View File
@@ -77,7 +77,6 @@ impl Document {
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
missing_pictures: Default::default(),
}
}
@@ -155,7 +154,6 @@ impl Document {
glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = self;
let Document {
body: other_body,
@@ -176,7 +174,6 @@ impl Document {
glossary: other_glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
+2 -9
View File
@@ -772,10 +772,6 @@ impl Document {
if a.path.parent() == b.path.parent() {
// Same container: trim the ends, drop the blocks between, join.
let (ia, ib) = (a.path.last(), b.path.last());
// Removing an already-empty paragraph must not impose its style or mark formatting
// on the following content. Test before trimming: deleting actual heading text is
// still an ordinary merge that keeps the first paragraph's properties.
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
// Kept markers of the paragraphs in between move to the join.
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
@@ -823,18 +819,15 @@ impl Document {
if ib < bl.len() && ia < ib {
bl.drain(ia + 1..=ib);
}
tail.props = ParaProps::default();
// The deleted marks include `a`'s, so a section break there goes too: the joined
// paragraph ends where `b`'s mark did, and its text takes the following section's
// setup, as in Word (#2127).
let sect = tail.section.take();
let pa = self.para_mut(a.story, &a.path)?;
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
let keep_mark = empty_start.then(|| tail.mark.clone());
let keep_props = pa.props.clone();
pa.append(tail);
pa.props = keep_props;
if let Some(mark) = keep_mark {
pa.mark = mark;
}
let before = std::mem::replace(&mut pa.section, sect);
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
// Nearest first, as "link to previous" resolved them before the delete.
+2 -5
View File
@@ -478,9 +478,8 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
/// differ, the page colour and picture, the watermark (one with no text isn't written), and the
/// notes' numbering.
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
@@ -491,10 +490,8 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
match serde_json::to_vec(&(
sections,
parts,
s.even_odd_headers,
&s.page_color,
&s.page_picture,
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
+2 -8
View File
@@ -75,7 +75,6 @@ mod block;
mod compare;
mod document;
mod error;
mod missing;
mod parts;
mod position;
mod settings;
@@ -85,15 +84,14 @@ pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH};
pub use compare::para_block;
use document::MediaIndex;
pub use error::{DocError, Result};
pub use missing::{MAX_MISSING_PICTURES, MissingPicture, MissingPictureReason, MissingPictures, picture_hash};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
pub(crate) use text_walk::walk_between;
pub use text_walk::{PARA_WALK_DEPTH, count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -140,10 +138,6 @@ pub struct Document {
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
/// Pictures a reader dropped because their target is missing, external or link-only (strict
/// export reports them); never saved, never drawn, ignored by `==`.
#[serde(skip)]
pub missing_pictures: MissingPictures,
}
#[cfg(test)]
-11
View File
@@ -19,12 +19,6 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -131,7 +125,6 @@ mod tests {
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
(r#"HYPERLINK "x \* Upper""#, Some("x * Upper")),
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
@@ -206,10 +199,6 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
-131
View File
@@ -1,131 +0,0 @@
//! Pictures a reader could not bring in or an exporter could not draw, for strict export (#6005).
//! Kept beside the document while it is open; never saved, never drawn, and not part of the
//! document's content (`==` ignores it).
use serde::{Deserialize, Serialize};
/// Most pictures remembered per document; past it [`MissingPictures::truncated`] is set.
pub const MAX_MISSING_PICTURES: usize = 256;
/// Most characters of a picture's part or story name a record keeps (names come from the file).
pub const MAX_PICTURE_NAME_CHARS: usize = 1024;
/// Why a picture is not in the document or not drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MissingPictureReason {
/// The relationship names a part the file does not contain, or no relationship at all.
MissingPart,
/// The target is outside the file (`TargetMode="External"`); it is never fetched.
ExternalTarget,
/// The picture only links to a file (`r:link`) and holds no embedded copy.
LinkOnly,
/// The bytes are a picture format we know but they do not decode.
Undecodable,
/// The bytes are not a picture format we can draw.
UnsupportedFormat,
}
impl MissingPictureReason {
/// The words used in reports (`"missing part"`, `"external target"`, `"link only"`,
/// `"undecodable"`, `"unsupported format"`).
pub fn label(self) -> &'static str {
match self {
Self::MissingPart => "missing part",
Self::ExternalTarget => "external target",
Self::LinkOnly => "link only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported format",
}
}
/// The reason a [`code`](Self::code) names.
pub fn from_code(code: &str) -> Option<Self> {
[Self::MissingPart, Self::ExternalTarget, Self::LinkOnly, Self::Undecodable, Self::UnsupportedFormat].into_iter().find(|r| r.code() == code)
}
/// The stable snake_case code the JSON report carries (`"missing_part"`, `"external_target"`,
/// `"link_only"`, `"undecodable"`, `"unsupported_format"`); never reworded.
pub fn code(self) -> &'static str {
match self {
Self::MissingPart => "missing_part",
Self::ExternalTarget => "external_target",
Self::LinkOnly => "link_only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported_format",
}
}
}
/// One picture that is missing.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MissingPicture {
/// The target as written, or the part name it resolved to (a media key for a decoded one).
pub part: String,
/// The story part it is in (`word/document.xml`, `word/header2.xml`), as the source file
/// names it; empty when not known.
pub story: String,
pub reason: MissingPictureReason,
}
/// A capped list of [`MissingPicture`]s, with the source names a report needs to say where a
/// picture is (a reader fills them; a document made or opened another way leaves them empty).
#[derive(Clone, Debug, Default)]
pub struct MissingPictures {
pub list: Vec<MissingPicture>,
/// More than [`MAX_MISSING_PICTURES`] were found; the rest are not listed.
pub truncated: bool,
/// Pictures found, those past the cap included (saturating). Each distinct one counts once.
pub total: u64,
/// The main story's part name in the source file (`word/document.xml`).
pub main: String,
/// A header, footer, note or comment story's part id ([`crate::Document::parts`]) to the
/// part it was read from (`word/header2.xml`). Ids freed and reused after opening may name
/// the old part.
pub part_names: std::collections::BTreeMap<u32, String>,
/// A [`crate::Document::media`] key to the part its bytes were read from
/// (`word/media/image2.bin`).
pub media_parts: std::collections::BTreeMap<String, String>,
/// What `list` holds, for an O(1) test of "already there" (a hostile file has many).
seen: std::collections::HashSet<MissingPicture>,
/// Hashes of the distinct pictures past the cap (8 bytes each), so the total counts them once.
unlisted: std::collections::HashSet<u64>,
}
impl MissingPictures {
/// Add one (the same part, story and reason once).
pub fn push(&mut self, mut item: MissingPicture) {
for name in [&mut item.part, &mut item.story] {
if name.len() > MAX_PICTURE_NAME_CHARS {
*name = name.chars().take(MAX_PICTURE_NAME_CHARS).collect();
}
}
if self.seen.contains(&item) {
return;
}
if self.list.len() >= MAX_MISSING_PICTURES {
self.truncated = true;
if self.unlisted.insert(picture_hash(&item)) {
self.total = self.total.saturating_add(1);
}
return;
}
self.total = self.total.saturating_add(1);
self.seen.insert(item.clone());
self.list.push(item);
}
}
/// A 64-bit hash of a record, to tell the ones past a cap apart without keeping them.
pub fn picture_hash(item: &MissingPicture) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
item.hash(&mut h);
h.finish()
}
impl PartialEq for MissingPictures {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
+9 -34
View File
@@ -228,7 +228,7 @@ impl Numbering {
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
/// one), without counting it.
pub fn shows_label(&self, num: u32, level: u8) -> bool {
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
}
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
@@ -325,9 +325,12 @@ impl Counters {
let lv = (level as usize).min(8);
// This list's own definition of a level wins over its abstract list's.
let level_def = |k: usize| numbering.level(num, k as u8);
// A "none" level (Word's numFmt none) still counts and restarts the levels below it; only
// its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985).
let def = level_def(lv)?.clone();
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
if def.format == NumFormat::None {
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
return None;
}
let st = self.state.entry(abs.id).or_insert([0; 9]);
let fl = self.flags.entry(abs.id).or_default();
// Only this level's override, not the num's others: the counters are shared by every
@@ -379,14 +382,7 @@ impl Counters {
let started = *fl;
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
// A "none" level shows its literal text, but has no number of its own for a reference to
// show (#4985): it is kept as one that shows none.
let counted = if def.format == NumFormat::None {
CountedLabel { label: None, levels: Vec::new() }
} else {
CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }
};
self.labels.insert((abs.id, lv as u8), counted);
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
Some((label, def))
}
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
@@ -459,9 +455,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
@@ -560,27 +556,6 @@ mod tests {
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
}
#[test]
fn a_none_level_shows_its_text_and_still_counts() {
// As Word shows them (#4985): a "none" level's literal text is its label, its own number
// reads as nothing (also in legal numbering), and it restarts the levels below it.
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() };
let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)];
}
assert!(n.shows_label(id, 0));
let mut c = Counters::default();
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
}
#[test]
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
-23
View File
@@ -186,11 +186,6 @@ pub struct Float {
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
#[serde(skip_serializing_if = "is_false")]
pub bare_turn: bool,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -246,7 +241,6 @@ impl Float {
self.rot = wordcraft_geom::normalize_degrees(s.deg);
self.flip_h = s.flip_h;
self.flip_v = s.flip_v;
self.bare_turn = false;
}
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
@@ -256,23 +250,6 @@ impl Float {
let (bw, bh) = self.spin().extent(w, h);
((bw - w) / 2.0, (bh - h) / 2.0)
}
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
pub fn is_bare_turn(&self) -> bool {
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
}
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
/// and from 225° up to 315°.
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
if !self.is_bare_turn() {
return self.spin_pad(w, h);
}
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
let deg = wordcraft_geom::normalize_degrees(self.rot);
if modern && ((45.0..135.0).contains(&deg) || (225.0..315.0).contains(&deg)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
}
/// The effect extents, finite and clamped (left, top, right, bottom).
pub fn effect_extent(&self) -> [f32; 4] {
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
+1 -2
View File
@@ -27,8 +27,7 @@ pub use char_props::{
};
pub use color::{Rgb, TextColor};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
TextAlignment, TextFrame,
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
-65
View File
@@ -26,67 +26,6 @@ impl Align {
}
}
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
///
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParagraphTextDirection {
LrTb,
LrTbV,
Tb,
TbRl,
TbRlV,
TbLr,
TbLrV,
BtLr,
TbV,
Rl,
RlV,
Lr,
LrV,
}
impl ParagraphTextDirection {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"lrTb" => Some(Self::LrTb),
"lrTbV" => Some(Self::LrTbV),
"tb" => Some(Self::Tb),
"tbRl" => Some(Self::TbRl),
"tbRlV" => Some(Self::TbRlV),
"tbLr" => Some(Self::TbLr),
"tbLrV" => Some(Self::TbLrV),
"btLr" => Some(Self::BtLr),
"tbV" => Some(Self::TbV),
"rl" => Some(Self::Rl),
"rlV" => Some(Self::RlV),
"lr" => Some(Self::Lr),
"lrV" => Some(Self::LrV),
_ => None,
}
}
pub fn ooxml(self) -> &'static str {
match self {
Self::LrTb => "lrTb",
Self::LrTbV => "lrTbV",
Self::Tb => "tb",
Self::TbRl => "tbRl",
Self::TbRlV => "tbRlV",
Self::TbLr => "tbLr",
Self::TbLrV => "tbLrV",
Self::BtLr => "btLr",
Self::TbV => "tbV",
Self::Rl => "rl",
Self::RlV => "rlV",
Self::Lr => "lr",
Self::LrV => "lrV",
}
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -452,9 +391,6 @@ pub struct ParaProps {
pub suppress_line_numbers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bidi: Option<bool>,
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_direction: Option<ParagraphTextDirection>,
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
@@ -526,7 +462,6 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
+2 -31
View File
@@ -137,12 +137,9 @@ impl ResolvedChar {
pub fn uses_complex(&self, c: char) -> bool {
self.rtl || self.cs || crate::bidi::is_complex_script(c)
}
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
/// 0.65 of the run's size rounded to the half point, a font size the format can store
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
pub fn draw_size(&self) -> f32 {
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
}
/// Baseline shift, points (positive = up).
pub fn baseline_shift(&self) -> f32 {
@@ -456,32 +453,6 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
mod tests {
use super::*;
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
/// its PDF export): 0.65 of the size, rounded to the half point.
#[test]
fn raised_and_lowered_text_is_sized_to_the_half_point() {
for (size, want) in [
(8.0, 5.0),
(9.0, 6.0),
(10.0, 6.5),
(11.0, 7.0),
(12.0, 8.0),
(13.0, 8.5),
(14.0, 9.0),
(16.0, 10.5),
(20.0, 13.0),
(24.0, 15.5),
(36.0, 23.5),
] {
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
}
}
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
}
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
/// style never gives one to its text.
#[test]
+4 -21
View File
@@ -289,14 +289,13 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
match self {
NumFormat::DecimalZero => format!("{n:02}"),
NumFormat::ThaiNumbers => thai_digits(n),
NumFormat::UpperRoman => roman(n).to_uppercase(),
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
@@ -350,6 +349,7 @@ impl NumFormat {
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
@@ -357,11 +357,6 @@ impl NumFormat {
}
}
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
fn thai_digits(n: u32) -> String {
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
}
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
fn chicago(n: u32) -> String {
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
@@ -1004,18 +999,6 @@ mod tests {
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
#[test]
fn thai_numbers_use_thai_digits() {
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
// Formats not drawn yet still show decimal.
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
+2 -7
View File
@@ -19,8 +19,8 @@
//!
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
/// The symbol table for a single family name, by lowercase family name.
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
Some(&SYMBOL)
@@ -37,11 +37,6 @@ fn single_table(family: &str) -> Option<&'static [u32; 224]> {
}
}
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
}
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
/// Webdings), whose characters [`symbol_char`] maps.
pub fn is_symbol_font(family: &str) -> bool {
-28
View File
@@ -786,31 +786,3 @@ fn lone_table_open_markers_mixed_tables_and_paired() {
assert!(res1.controls.open.is_empty());
assert_eq!(res1.controls.close, 0);
}
#[test]
fn missing_picture_names_are_capped_at_a_char_boundary() {
let mut d = Document::new();
let long: String = "é".repeat(5000);
d.missing_pictures.push(MissingPicture { part: long.clone(), story: long, reason: MissingPictureReason::MissingPart });
let item = &d.missing_pictures.list[0];
assert_eq!((item.part.chars().count(), item.story.chars().count()), (1024, 1024));
}
#[test]
fn missing_pictures_are_capped_deduplicated_and_not_content() {
let mut d = Document::new();
let item = |n: usize| MissingPicture { part: format!("p{n}"), story: "word/document.xml".into(), reason: MissingPictureReason::MissingPart };
d.missing_pictures.push(item(0));
d.missing_pictures.push(item(0));
assert_eq!(d.missing_pictures.list.len(), 1);
for n in 1..MAX_MISSING_PICTURES + 3 {
d.missing_pictures.push(item(n));
}
assert_eq!(d.missing_pictures.list.len(), MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
let e = Document::new();
assert_eq!(d, e);
assert!(d.same_content(&e));
assert!(!serde_json::to_string(&d).unwrap().contains("p0"));
assert_eq!(MissingPictureReason::ExternalTarget.label(), "external target");
}
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn count_chars_no_spaces(s: &str) -> usize {
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
pub const PARA_WALK_DEPTH: usize = 16;
/// The note and text box stories an object shows, for `Document::reachable`.
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
-158
View File
@@ -1,158 +0,0 @@
use std::sync::Arc;
use wordcraft_doc::props::{Align, CharProps, ParaProps};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
fn fixture(first: &str, style: Option<&str>) -> Document {
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.style = Some("Heading1".into());
p.props.outline_level = Some(0);
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
p.props = ParaProps {
style: style.map(str::to_string),
align: Some(Align::Right),
indent_left: Some(18.0),
space_after: Some(7.0),
keep_next: Some(false),
..Default::default()
};
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
d
}
/// Compare paragraph, mark and inline-run formatting independently of text.
fn assert_format(p: &Paragraph, expected: &Paragraph) {
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
}
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
#[test]
fn deleting_an_empty_paragraph_keeps_the_following_format() {
for first_style in ["Heading1", "Heading3", "Normal"] {
for style in [None, Some("Normal"), Some("Heading2")] {
let mut d = fixture("", style);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, expected.text);
assert_format(p, &expected);
}
}
}
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
#[test]
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
for backwards in [false, true] {
let mut d = fixture("", None);
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
expected.delete(0, 5).unwrap();
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
assert_eq!(d.body.len(), 1);
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "paragraph.");
assert_format(p, &expected);
}
}
/// A control whose closing marker survives must move across the join without replacing tail formatting.
#[test]
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
use wordcraft_doc::control::ContentControl;
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
let mut d = fixture("", Some("Normal"));
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
p.mark.ins = Some(0);
p.mark.del = Some(1);
let expected = p.clone();
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props, expected.props);
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
assert_eq!(p.props_of_char(0), &marker);
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
let ranges = d.control_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, Pos::body(0, 0));
assert_eq!(ranges[0].end, Pos::body(1, 5));
assert!(!d.has_unbalanced_controls());
}
/// The empty-paragraph rule applies in header stories and table-cell containers.
#[test]
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
for cell in [false, true] {
let mut d = fixture("", Some("Normal"));
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let (story, a, b) = if cell {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = d.body.clone();
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
} else {
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
(StoryRef::Part(id), Path::top(0), Path::top(1))
};
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
assert_format(d.para(story, &a).unwrap(), &expected);
}
}
/// Absorbing an empty section keeps the following format and inherited header.
#[test]
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
let mut d = fixture("", None);
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
let mut section = d.last_section.clone();
section.headers.default = Some(header);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
d.last_section.margin_left = 96.0;
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
assert_eq!(d.last_section.headers.default, Some(header));
assert_eq!(d.last_section.margin_left, 96.0);
}
/// Deleting actual text continues to use the first paragraph merge rule.
#[test]
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
for off in [0, 7] {
let mut d = fixture("Heading", None);
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
}
/// Removing a soft line break must not change paragraph formatting.
#[test]
fn a_soft_break_is_not_an_empty_paragraph_merge() {
let mut d = fixture("", None);
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
let expected = p.props.clone();
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
assert_eq!(d.body.len(), 2);
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
+1 -5
View File
@@ -211,11 +211,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
kind = ShapeKind::TextBox;
}
let line = kind == ShapeKind::Line;
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
// drawn as their box here, which a default fill would make an opaque block over the text.
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
// Line widths are EMUs; Word's default is 0.75 pt.
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
+13 -52
View File
@@ -7,26 +7,9 @@ use crate::DocbinError;
/// Largest bin table (entries) we accept.
const MAX_BINS: usize = 100_000;
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
#[derive(Clone, Copy)]
pub(crate) struct ChpxRun<'w> {
pub(crate) grpprl: &'w [u8],
lo: u32,
hi: u32,
}
impl ChpxRun<'_> {
pub(crate) fn contains(&self, fc: u32) -> bool {
self.lo <= fc && fc < self.hi
}
}
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
pub(crate) struct Bins {
fcs: Vec<u32>,
/// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt
/// table that does not gets no cached ranges, [`ChpxRun`]).
sorted: bool,
/// Page number (× 512 = byte offset in the WordDocument stream).
pns: Vec<u32>,
}
@@ -37,7 +20,7 @@ impl Bins {
let fc = fc as usize;
let lcb = lcb as usize;
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() });
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
}
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
@@ -55,58 +38,36 @@ impl Bins {
// PnBte* keeps the page number in its low bits; the rest must be zero.
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
}
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
Ok(Bins { fcs, sorted, pns })
Ok(Bins { fcs, pns })
}
/// The page containing `fc`, as a slice of the WordDocument stream, and the FC range
/// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table
/// is not ascending, so nothing is cached from it).
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> {
/// The page containing `fc`, as a slice of the WordDocument stream.
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
let pn = *self.pns.get(i)?;
let at = pn.checked_mul(512)? as usize;
let page = word.get(at..at.checked_add(512)?)?;
if !self.sorted {
return Some((page, 1, 0));
}
let lo = self.fcs.get(i).copied().unwrap_or(0);
let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX);
Some((page, lo, hi))
word.get(at..at.checked_add(512)?)
}
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
/// crun in the last byte).
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
self.chpx_run(word, fc).grpprl
}
/// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk
/// over consecutive characters looks it up once per run, not once per character.
pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> {
let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 };
let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none };
let Some(&crun) = page.get(511) else { return none };
let Some(page) = self.page(word, fc) else { return &[] };
let Some(&crun) = page.get(511) else { return &[] };
let crun = crun.min(0x65) as usize;
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
// region between rgb's end and crun.
let rgb_at = (crun + 1) * 4;
// The last run starting at or before `fc`. It stays the answer for every FC from its own
// start up to the smallest start after it (also in a page whose starts do not ascend).
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
let first_after = j.map_or(0, |j| j + 1);
let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi));
let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo));
let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi };
let Some(j) = j else { return run(&[]) };
let Some(&off) = page.get(rgb_at + j) else { return run(&[]) };
let Some(j) = j else { return &[] };
let Some(&off) = page.get(rgb_at + j) else { return &[] };
if off == 0 {
return run(&[]);
return &[];
}
let at = off as usize * 2;
let Some(&cb) = page.get(at) else { return run(&[]) };
run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]))
let Some(&cb) = page.get(at) else { return &[] };
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
}
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
@@ -114,7 +75,7 @@ impl Bins {
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) };
let Some(page) = self.page(word, fc) else { return (0, &[]) };
let Some(&crun) = page.get(511) else { return (0, &[]) };
// BX is 13 bytes; cap crun at what fits before byte 511.
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
+44 -86
View File
@@ -260,9 +260,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
};
let mut doc = Document::new();
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
// 15 would apply Word 2013+ justification and line-breaking rules to them.
doc.settings.compat_mode = 11;
if let Some(core) = metadata::read(&mut comp) {
doc.core = core;
}
@@ -505,37 +502,29 @@ struct ParaBuild {
runs: Vec<Run>,
props: wordcraft_doc::props::ParaProps,
mark: CharProps,
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
/// the last run is still the one it extended: the same generation means equal properties.
run_gen: Option<u64>,
/// The current run's properties, so runs only break when formatting changes.
cur: Option<CharProps>,
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
objects: Vec<InlineObject>,
}
impl ParaBuild {
fn push(&mut self, c: char, props: CharProps) {
self.run_gen = None;
let len = c.len_utf8();
match self.runs.last_mut() {
Some(r) if r.props == props => r.len += len,
_ => self.runs.push(Run { len, props }),
match &self.cur {
Some(p) if *p == props => {
if let Some(r) = self.runs.last_mut() {
r.len += len;
}
}
_ => {
self.runs.push(Run { len, props: props.clone() });
self.cur = Some(props);
}
}
self.text.push(c);
}
/// [`ParaBuild::push`] for properties that are resolved once and shared by many characters:
/// they are cloned only when a run starts, and not even compared while `generation` stays
/// the same.
fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len,
_ => self.runs.push(Run { len, props: props.clone() }),
}
self.run_gen = Some(generation);
self.text.push(c);
}
/// Append `c` to the current run, whose properties the caller knows it matches.
fn extend(&mut self, c: char) {
if let Some(r) = self.runs.last_mut() {
@@ -563,7 +552,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
// Field assembly state: instruction characters are swallowed, the result is kept until
// the end character, then emitted as one object (hyperlinks keep their result text).
let mut field: Option<FieldBuild> = None;
let mut fmt = FmtState::default();
let mut memo: Option<FmtMemo<'a>> = None;
while cp < cp_end {
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
// an empty table and `cp` never matches the main-document CPs).
@@ -726,16 +715,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
}
}
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt),
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
// Object anchors, comment references and other special characters are dropped
// (or handled by later passes).
c if (c as u32) < 0x20 => {}
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
}
cp = cp.saturating_add(units);
}
@@ -865,53 +854,34 @@ fn num_of(ilfo: i32) -> Option<u32> {
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
/// the character is recorded into the instruction or the cached result instead of (or as
/// well as) the visible text.
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) {
// The piece and the CHPX run of the previous character usually cover this one too.
let span = match st.span.filter(|s| s.contains(cp)) {
Some(s) => Some(s),
None => {
st.span = ctx.pieces.span(cp);
st.span
}
};
let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0);
let run = match st.chpx.filter(|r| r.contains(fc)) {
Some(r) => r,
None => {
let r = ctx.chpx_bins.chpx_run(ctx.word, fc);
st.chpx = Some(r);
r
}
};
let grpprl = run.grpprl;
let prm = span.map_or(0, |s| s.prm());
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
let prm = ctx.pieces.prm_of(cp);
let style = pb.props.style.as_deref();
let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style);
if !hit {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(sty) => &sty.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props });
st.generation += 1;
}
let Some(m) = st.memo.as_ref() else { return };
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
Some(m) => m.props.clone(),
None => {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
props
}
};
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
let symbol = if c == '(' { symbol_operand(grpprl) } else { None };
if field.is_none() && symbol.is_none() {
pb.push_shared(c, &m.props, st.generation);
return;
}
let mut props = m.props.clone();
let mut c = c;
if let Some((ftc, xchar)) = symbol {
if c == '('
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
{
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
props.font = Some(font.clone());
}
@@ -930,18 +900,6 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
}
}
/// What the walk carries from one character to the next while it resolves formatting: the piece
/// and the CHPX run the last character was in (so most characters need no search), and the last
/// formatting resolved.
#[derive(Default)]
struct FmtState<'a> {
span: Option<piece::PieceSpan<'a>>,
chpx: Option<fkp::ChpxRun<'a>>,
memo: Option<FmtMemo<'a>>,
/// Counts the memo's replacements, so equal values mean the same properties.
generation: u64,
}
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
/// formatting change, not once per character (#3692).
+6 -36
View File
@@ -117,6 +117,12 @@ impl PieceTable {
}
}
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
/// with the same value get the same [`PrmRef`].
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
self.piece_of(cp).map_or(0, |p| p.prm)
}
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
if i >= self.pieces.len() {
@@ -137,17 +143,6 @@ impl PieceTable {
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
}
/// The piece holding `cp`, with the CP range it covers: for every CP in that range the
/// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone,
/// so a sequential walk searches the piece table once per piece.
pub(crate) fn span(&self, cp: u32) -> Option<PieceSpan<'_>> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
let pcd = self.pieces.get(i)?;
let cp_lo = *self.cps.get(i)?;
let cp_hi = *self.cps.get(i + 1)?;
Some(PieceSpan { cp_lo, cp_hi, pcd })
}
/// The CP just past the last piece: no text exists at or beyond it.
pub(crate) fn cp_end(&self) -> u32 {
self.cps.last().copied().unwrap_or(0)
@@ -192,31 +187,6 @@ impl PieceTable {
}
}
/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]).
#[derive(Clone, Copy)]
pub(crate) struct PieceSpan<'a> {
cp_lo: u32,
cp_hi: u32,
pcd: &'a Pcd,
}
impl PieceSpan<'_> {
pub(crate) fn contains(&self, cp: u32) -> bool {
self.cp_lo <= cp && cp < self.cp_hi
}
/// [`PieceTable::fc_of_cp`] for a CP inside the span.
pub(crate) fn fc_at(&self, cp: u32) -> Option<u32> {
let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?;
if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) }
}
/// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`].
pub(crate) fn prm(&self) -> u16 {
self.pcd.prm
}
}
/// What a piece's `Prm` refers to.
pub(crate) enum PrmRef<'a> {
None,
-159
View File
@@ -337,15 +337,6 @@ fn empty_document_gets_a_paragraph() {
assert_eq!(doc.body.len(), 1);
}
#[test]
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
// line breaking.
assert_eq!(doc.settings.compat_mode, 11);
}
#[test]
fn header_text_stays_out_of_body() {
let f = text_doc(
@@ -2889,153 +2880,3 @@ fn field_results_keep_their_text_and_runs() {
p.objects
);
}
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
let mut word = vec![0u8; 512];
word.extend_from_slice(page);
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
(word, bins)
}
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
/// reuses it for the following characters without asking again.
#[test]
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
}
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
/// a range that never claims an FC with a different answer.
#[test]
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc));
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
}
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
/// and UTF-16) and past the last.
#[test]
fn a_piece_span_matches_the_per_cp_lookups() {
let doc = text_doc(
&[
Piece { compressed: true, text: "abc".into() },
Piece { compressed: false, text: "d\u{e9}fgh".into() },
Piece { compressed: true, text: "ij".into() },
],
&[],
0,
);
let mut comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&mut comp, "0Table").expect("table");
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
for cp in 0..14 {
let span = pieces.span(cp);
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
for other in 0..14 {
if span.is_some_and(|s| s.contains(other)) {
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
}
}
}
assert!(pieces.span(10).is_none(), "past the last piece");
}
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
/// even compared while the formatting stays the same.
#[test]
fn shared_props_start_one_run_per_formatting() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut pb = super::ParaBuild::default();
for _ in 0..100_000 {
pb.push_shared('a', &bold, 1);
}
pb.push_shared('b', &CharProps::default(), 2);
pb.push_shared('c', &bold, 3);
pb.push_shared('d', &bold, 4);
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
pb.push('e', bold.clone());
pb.push_shared('f', &bold, 4);
assert_eq!(
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
[100_000, 1, 4],
"a plain push forgets the generation but still joins an equal run"
);
assert_eq!(pb.text.len(), 100_005);
}
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
/// run is reported as valid it gives the answer a fresh lookup gives.
#[test]
fn chpx_runs_are_sound_across_bins() {
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
let mut word = vec![0u8; 512];
word.extend_from_slice(&page_a);
word.extend_from_slice(&page_b);
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
let plc = |fcs: &[u32], pns: &[u32]| {
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
};
let tables = [
("two bins", plc(&[0, a, end], &[1, 2])),
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
("a missing page", plc(&[0, a, end], &[1, 9])),
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
];
for (what, t) in &tables {
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
for &fc in &probes {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
for &other in &probes {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
}
}
}
}
}
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
/// (with a field and a symbol character in between), and nowhere else.
#[test]
fn formatting_runs_break_where_the_chpx_changes() {
let spec = RichSpec {
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
papx_entries: vec![(30, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let p = para0(&doc);
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
// (the run from the field to CP 20), "d(ee" italic.
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
}
-35
View File
@@ -183,41 +183,6 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
#[test]
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
// white fill.
let brace = sp(&[fsp(88, 1026, 0xA00)]);
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
let rect = sp(&[fsp(1, 1028, 0xA00)]);
let art = art_content(&[], &[brace, bracket, rect]);
let flags = spa_flags(0, 0, 3, false);
let spas =
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
let text = "A\u{8}\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let fills: Vec<_> = p0
.objects
.iter()
.map(|o| match o {
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
fills,
[
(None, Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
]
);
}
#[test]
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
+1 -20
View File
@@ -350,7 +350,6 @@ impl Package {
let path = resolve(dir, target);
self.canonical(&path).map_or(path, str::to_string)
};
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
}
rels
@@ -406,13 +405,11 @@ pub struct Rel {
#[derive(Clone, Debug, Default)]
pub struct Rels {
pub list: Vec<Rel>,
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
ids: HashMap<String, usize>,
}
impl Rels {
pub fn by_id(&self, id: &str) -> Option<&Rel> {
self.ids.get(id).and_then(|&i| self.list.get(i))
self.list.iter().find(|r| r.id == id)
}
pub fn by_type(&self, full: &str) -> Option<&Rel> {
self.list.iter().find(|r| rel_is(&r.kind, full))
@@ -550,22 +547,6 @@ mod tests {
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
}
#[test]
fn relationships_are_looked_up_by_id_the_first_one_winning() {
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..20_000 {
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
}
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
let r = Package::open(&z).unwrap().rels("word/document.xml");
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
assert!(r.by_id("rId20000").is_none());
// Hyperlink-style lookups, one per relationship, are each a map lookup.
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
}
#[test]
fn decompression_bomb_is_rejected() {
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
+1 -10
View File
@@ -27,8 +27,7 @@ use wordcraft_doc::props::VAlign;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, MissingPicture, MissingPictureReason, PagePicture, Paragraph, PartKind, Revision, RevisionKind,
Zoom, ZoomFit, para_block,
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
};
use crate::DocxError;
@@ -104,8 +103,6 @@ pub(crate) struct Reader<'p> {
pub shape_ids: bool,
/// The main document part's name.
main: String,
/// The part being read (`word/header2.xml`), for naming where a dropped picture was.
story: String,
/// The package's content types, read when first needed.
content_types: Option<ContentTypes>,
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
@@ -186,7 +183,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
glued: HashSet::new(),
shape_ids: false,
main: main.clone(),
story: main.clone(),
content_types: None,
embedded: EmbeddedManifest::default(),
txbx_links: Vec::new(),
@@ -194,7 +190,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
group_fill: None,
chunks,
};
r.doc.missing_pictures.main = main.clone();
r.pc.major_font = r.doc.settings.major_font.clone();
r.pc.minor_font = r.doc.settings.minor_font.clone();
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
@@ -235,18 +230,14 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
let ex = part(rt::COMMENTS_EX);
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
r.story = p.clone();
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
if let Some(p) = part(rt::FOOTNOTES) {
r.story = p.clone();
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
r.story = p.clone();
lenient("endnotes", r.read_notes(&p, false));
}
r.story = main.clone();
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
-3
View File
@@ -66,9 +66,6 @@ pub(super) struct Axis {
pub(super) struct Series {
pub(super) name: String,
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
/// when missing or not a valid number.
pub(super) idx: Option<usize>,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
+2 -6
View File
@@ -91,11 +91,8 @@ impl Chart {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
/// doesn't matter), else the one its place in the file picks.
pub(super) fn series_colour(&self, j: usize) -> Rgb {
let s = self.series.get(j);
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
@@ -202,7 +199,6 @@ pub(super) fn text_size(space: &El) -> f32 {
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
@@ -223,7 +219,7 @@ impl Series {
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
-62
View File
@@ -528,65 +528,3 @@ fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
// `c:order` changes nothing.
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
sers.concat()
);
let items = with_legend(&plot, "r", 300.0, 200.0);
let bars = filled(&items).iter().map(|b| b.0).collect();
let keys = items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
.collect();
(bars, keys)
}
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
fn renumbered(ser: &str, idx_order: &str) -> String {
let start = ser.find("<c:idx").expect("ser writes c:idx");
let end = ser.find("<c:tx>").expect("ser writes c:tx");
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
}
#[test]
fn automatic_series_colours_follow_c_idx() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
// A lone series with idx 2 is accent3.
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&lone).0, [accent(2)]);
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
// A series' own colour still wins.
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
}
#[test]
fn series_without_a_usable_c_idx_take_their_place() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// No c:idx at all: the place in the file.
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
// Junk, negative and out-of-range values: no panic, the place in the file.
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
}
// The largest unsignedInt `c:idx` allows draws, without a panic.
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
assert_eq!(column_colours(&big).0.len(), 1);
}
-15
View File
@@ -80,21 +80,6 @@ impl Reader<'_> {
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
// is their own already).
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
// The pictures the chunk's own reader dropped are the file's too (strict export, #6005),
// each named by the chunk and its part in it (`word/afchunk1.docx > word/header1.xml`).
// A chunk in another format brings none: those importers record nothing yet (#6007).
let nested = std::mem::take(&mut doc.missing_pictures);
let list_len = nested.list.len() as u64;
for mut item in nested.list {
let inner = if item.story.is_empty() { nested.main.as_str() } else { item.story.as_str() };
item.story = format!("{part} > {inner}");
self.doc.missing_pictures.push(item);
}
// The nested records past its list are the file's too; their stories carry the chunk's
// part, so none is one the outer list already counted.
let unlisted = nested.total.saturating_sub(list_len);
self.doc.missing_pictures.total = self.doc.missing_pictures.total.saturating_add(unlisted);
self.doc.missing_pictures.truncated |= nested.truncated;
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
// Bookmarks and comment marks read before the chunk stay before it.
self.flush_pending_before(sc, out, &mut blocks);
+1 -24
View File
@@ -239,13 +239,7 @@ impl Reader<'_> {
/// Keep one part's bytes and content type (not its relationships).
fn keep_part(&mut self, part: &str) -> bool {
let lower = part.to_ascii_lowercase();
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
if !manifest_field_ok(part)
|| lower.ends_with(".rels")
|| lower == "[content_types].xml"
|| lower.starts_with("wordcraft:")
|| part.eq_ignore_ascii_case(&self.main)
{
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
log::warn!("docx: not carrying part {part:?} for an embedded object");
return false;
}
@@ -394,23 +388,6 @@ mod tests {
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
#[test]
fn a_part_named_like_bookkeeping_is_not_carried() {
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
let bytes = package(
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
);
let doc = crate::read(&bytes).unwrap();
let roots = unmodelled_roots(&doc);
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
assert!(!manifest.contains("evil"), "{manifest}");
}
/// An unqualified attribute is a relationship only where the markup names a relationship
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
#[test]
-3
View File
@@ -53,10 +53,7 @@ impl Reader<'_> {
// Without content types (a converted package) the root element alone decides.
if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) {
let rels = self.pkg.rels(&rel.target);
// Pictures in a building block belong to the glossary part, not the main story.
let outer = std::mem::replace(&mut self.story, rel.target.clone());
entries = self.doc_parts(&root, &rels);
self.story = outer;
}
}
self.doc.glossary = entries;
-2
View File
@@ -117,7 +117,6 @@ impl Reader<'_> {
// Reserve the id first: the note's own reference mark points at it.
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
let id = self.doc.add_part(pk, Blocks::new());
self.doc.missing_pictures.part_names.insert(id, self.story.clone());
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.current_note = Some((kind, id));
@@ -215,7 +214,6 @@ impl Reader<'_> {
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
self.flush_pending(&mut sc, &mut blocks);
let part = self.doc.add_part(PartKind::Comment, blocks);
self.doc.missing_pictures.part_names.insert(part, self.story.clone());
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
by_para.insert(pid.to_string(), id);
}
+4 -25
View File
@@ -1,6 +1,6 @@
use super::{
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, MissingPicture,
MissingPictureReason, PartKind, Reader, Rels, Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
};
impl Reader<'_> {
@@ -55,8 +55,6 @@ impl Reader<'_> {
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.flat_map(|n| n.split(';'))
.map(str::trim)
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
@@ -91,22 +89,14 @@ impl Reader<'_> {
/// Media key for an image relationship (loads the bytes once).
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
let Some(rel) = rels.by_id(id) else {
self.note_missing_picture(id, MissingPictureReason::MissingPart);
return None;
};
let rel = rels.by_id(id)?;
if rel.external {
// Never fetched: a picture whose target is outside the file is dropped (and reported).
self.note_missing_picture(&rel.target, MissingPictureReason::ExternalTarget);
return None;
}
if let Some(k) = self.media_by_path.get(&rel.target) {
return Some(k.clone());
}
let Some(bytes) = self.pkg.get_shared(&rel.target) else {
self.note_missing_picture(&rel.target, MissingPictureReason::MissingPart);
return None;
};
let bytes = self.pkg.get_shared(&rel.target)?;
let base: String =
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
@@ -120,18 +110,10 @@ impl Reader<'_> {
};
}
self.doc.media.insert(key.clone(), bytes.clone());
self.doc.missing_pictures.media_parts.insert(key.clone(), rel.target.clone());
self.media_by_path.insert(rel.target.clone(), key.clone());
Some(key)
}
/// Remember a picture that was dropped at open, in the story being read.
pub(super) fn note_missing_picture(&mut self, part: &str, reason: MissingPictureReason) {
let part: String = part.chars().take(1024).collect();
let story = self.story.clone();
self.doc.missing_pictures.push(MissingPicture { part, story, reason });
}
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
if let Some(p) = self.graphic_parts.get(path) {
@@ -153,7 +135,6 @@ impl Reader<'_> {
let kind = if footer { PartKind::Footer } else { PartKind::Header };
let id = self.doc.add_part(kind, Blocks::new());
self.hf_by_path.insert(path.to_string(), id);
self.doc.missing_pictures.part_names.insert(id, path.to_string());
let root = match self.xml(path) {
Ok(Some(r)) => r,
_ => return Some(id),
@@ -163,9 +144,7 @@ impl Reader<'_> {
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
let outer = std::mem::replace(&mut self.story, path.to_string());
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
self.story = outer;
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
+10 -41
View File
@@ -5,17 +5,16 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
TextDirection, Underline, VAlign, VMerge, VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
PageVAlign, SectionProps, SectionStart,
};
use super::MAX_FONT_NAME;
use super::drawing_color::adjust_lum;
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -146,29 +145,6 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
}
}
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
/// drop an over-long name.
fn bounded_font_list(v: &str) -> Option<String> {
if v.len() <= MAX_FONT_NAME {
return (!v.is_empty()).then(|| v.to_string());
}
let mut end = 0usize;
for part in v.split_inclusive(';') {
let next = end + part.len();
if next > MAX_FONT_NAME {
break;
}
end = next;
}
let kept = v.get(..end)?.trim_end_matches(';');
(!kept.is_empty()).then(|| kept.to_string())
}
impl PropCtx {
pub fn style_id(&self, id: &str) -> String {
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
@@ -250,8 +226,6 @@ impl PropCtx {
shd_fill(e)
}
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
/// weight and posture of the complex-script and East Asian text beside them.
pub fn rpr(&self, e: &El) -> CharProps {
let mut c = CharProps::default();
for k in e.els() {
@@ -260,11 +234,7 @@ impl PropCtx {
"w:rFonts" => {
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
// A LibreOffice file names the Latin font as a semicolon-separated fallback
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
// corrupt or hostile, and every piece of text drawn in it would pay for its
// whole length as the list is classified (see `bounded_font_list`).
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
@@ -368,7 +338,6 @@ impl PropCtx {
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
@@ -407,10 +376,10 @@ impl PropCtx {
p.tabs = Some(tabs);
}
"w:spacing" => {
if let Some(v) = tw_u16(k, "w:before") {
if let Some(v) = tw(k, "w:before") {
p.space_before = Some(v.max(0.0));
}
if let Some(v) = tw_u16(k, "w:after") {
if let Some(v) = tw(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
@@ -433,15 +402,15 @@ impl PropCtx {
}
}
"w:ind" => {
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
p.indent_left = Some(v);
}
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
p.indent_right = Some(v);
}
if let Some(v) = tw_i16(k, "w:hanging") {
if let Some(v) = tw(k, "w:hanging") {
p.indent_first = Some(-v);
} else if let Some(v) = tw_i16(k, "w:firstLine") {
} else if let Some(v) = tw(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
+6 -46
View File
@@ -41,7 +41,6 @@ impl Reader<'_> {
}
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
let (px, py) = float.spin_pad(w, h);
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
if *e != 0.0 {
@@ -107,11 +106,7 @@ impl Reader<'_> {
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
// Word's effect extent also covers a rotated object's overhang; the model keeps that
// apart (it follows from the angle), so take it back out.
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
if let Some(f) = obj.float_mut() {
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
let (px, py) = f.spin_pad(w, h);
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
// Zero adds no effects room when rotation contracts this axis.
@@ -123,33 +118,9 @@ impl Reader<'_> {
Some(obj)
}
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
/// part, when the address has one, stays part of the address.
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
}
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
/// choice's link is not this object's.
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
};
let link =
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
Some((object, link))
}
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
let drawing = choice.child("w:drawing")?;
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
@@ -178,8 +149,10 @@ impl Reader<'_> {
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
/// fallback picture, keeping both branches so saving writes the chart back rather than only
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = chartex_choice(ac)?;
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = ac
.children("mc:Choice")
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
// A requirement in a namespace we can't name couldn't be written back.
let requires = choice.attr("Requires").unwrap_or("");
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
@@ -279,15 +252,7 @@ impl Reader<'_> {
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, mut float: Float) -> Option<InlineObject> {
let blip = pic.find("a:blip")?;
float.set_spin(xfrm_spin(pic.find("pic:spPr")));
let Some(embed) = blip.attr("r:embed") else {
// A linked picture holds no copy: nothing to draw, and nothing is fetched.
if let Some(link) = blip.attr("r:link") {
let target = rels.by_id(link).map_or_else(|| link.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let media = self.media_for(rels, embed)?;
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let mut crop = [0.0f32; 4];
if let Some(sr) = pic.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
@@ -598,11 +563,6 @@ impl Reader<'_> {
}
}
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
fn chartex_choice(ac: &El) -> Option<&El> {
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
}
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
+5 -13
View File
@@ -98,9 +98,9 @@ impl Reader<'_> {
}
}
"mc:AlternateContent" => {
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
let props = self.run_props_none(ctx);
self.emit_linked_obj(sc, pb, o, &props, link);
self.emit_obj(sc, pb, o, &props);
} else if let Some(c) = children_of_choice(k) {
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
}
@@ -237,7 +237,7 @@ impl Reader<'_> {
},
"w:drawing" => {
if let Some(o) = self.read_drawing(sc, k, rels) {
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
self.emit_obj(sc, pb, o, props);
}
}
"w:pict" => {
@@ -298,8 +298,8 @@ impl Reader<'_> {
),
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
self.emit_linked_obj(sc, pb, o, props, link);
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
self.emit_obj(sc, pb, o, props);
} else if let Some(c) = children_of_choice(k) {
for x in c.els() {
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
@@ -376,14 +376,6 @@ impl Reader<'_> {
}
}
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
match link.filter(|_| props.link.is_none()) {
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
None => self.emit_obj(sc, pb, o, props),
}
}
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
match sink(sc) {
Some(f) => {
+1 -17
View File
@@ -131,14 +131,7 @@ impl Reader<'_> {
None => vml_float(shape, style, sc.in_hf),
};
if let Some(img) = shape.find("v:imagedata") {
let Some(id) = img.attr("r:id").or_else(|| img.attr("r:pict")) else {
// `r:href` names a file outside the package: never fetched.
if let Some(href) = img.attr("r:href") {
let target = rels.by_id(href).map_or_else(|| href.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
@@ -231,15 +224,6 @@ impl Reader<'_> {
{
let id = self.read_textbox(sc, t, rels);
if let Some(part) = self.doc.parts.get_mut(&id) {
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
let tb_style = tb.attr("style").unwrap_or("");
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert;
}
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
// `o:insetmode="auto"` leaves them to the application.
+3 -4
View File
@@ -2,7 +2,6 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use crate::units::tw_i16;
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -318,12 +317,12 @@ impl Reader<'_> {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw_i16(ind, "w:hanging") {
if let Some(h) = tw(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
} else if let Some(f) = tw(ind, "w:firstLine") {
lv.hanging = -f;
}
}
+3 -4
View File
@@ -83,14 +83,13 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
}
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
if empty_heading {
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
}
if crate::write::written_toc_span(&engine) == Some((s, last)) {
*body = if empty_heading { toc } else { engine };
*body = toc;
}
}
@@ -98,7 +97,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
field_to_heading(body, i);
}
}
-39
View File
@@ -116,27 +116,6 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
@@ -169,24 +148,6 @@ pub fn emu(pt: f32) -> String {
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
+7 -28
View File
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = HashSet::new();
let mut used = Vec::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
if !used.iter().all(|id| writable.contains(id.as_str())) {
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
@@ -499,9 +499,8 @@ impl super::Writer<'_> {
}
}
"wp:effectExtent" => {
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
// unless the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(w, h);
let [el, et, er, eb] = float.effect_extent();
let ext = [el + px, et + py, er + px, eb + py].map(emu);
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
@@ -638,15 +637,13 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
/// never went through the reader's id limit) costs no scan per attribute.
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.insert(v.clone());
out.push(v.clone());
}
}
for c in e.els() {
@@ -854,24 +851,6 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn relationship_ids_are_collected_once_each() {
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
// (each named twice) were a scan of every id collected so far, per attribute.
let mut xml = String::from(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
for i in 0..40_000 {
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
}
xml.push_str("</w:p>");
let root = xml::parse(xml.as_bytes()).unwrap();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
assert_eq!(used.len(), 40_000);
assert!(used.contains("rId39999"));
}
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
-3
View File
@@ -287,9 +287,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(direction) = p.text_direction {
w.val("w:textDirection", direction.ooxml());
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
+1 -18
View File
@@ -86,28 +86,11 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
})
};
// Bookmark, comment and permission markers after the end may stay there: a body-level
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
// and the control still covers the whole paragraph.
let at_end = |b: usize, off: usize| {
use wordcraft_doc::InlineObject as O;
para(b).is_some_and(|p| {
let from = off + OBJ.len_utf8();
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
matches!(
p.object_at(o),
Some(
O::ControlEnd
| O::BookmarkStart { .. }
| O::BookmarkEnd { .. }
| O::CommentStart { .. }
| O::CommentEnd { .. }
| O::PermStart { .. }
| O::PermEnd { .. }
)
)
})
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
})
};
let mut mode = vec![Mode::Inline; ranges.len()];
+14 -31
View File
@@ -10,19 +10,9 @@ pub(super) struct DrawingInfo<'a> {
pub docpr: &'a str,
pub name: &'a str,
pub alt: &'a str,
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
pub link: Option<&'a str>,
}
impl Writer<'_> {
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
Some(rels.add(rt::HYPERLINK, url, true))
}
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
@@ -30,7 +20,7 @@ impl Writer<'_> {
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
@@ -76,7 +66,7 @@ impl Writer<'_> {
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = shape_name(*kind, freeform.as_deref(), id);
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
}
if kind.is_connector() {
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
@@ -245,7 +235,7 @@ impl Writer<'_> {
}
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
if float.wrap == Wrap::Inline {
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
@@ -298,9 +288,8 @@ impl Writer<'_> {
}
}
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
// the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(iw, ih);
let [el, et, er, eb] = float.effect_extent();
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
@@ -327,7 +316,7 @@ impl Writer<'_> {
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
}
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
}
}
@@ -513,9 +502,8 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
/// from a file with no description) is not written twice. `link` is the relationship id of the
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
/// from a file with no description) is not written twice.
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
@@ -526,21 +514,16 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str),
if hidden {
attrs.push(("hidden", "1"));
}
if !alt.decorative && link.is_none() {
if !alt.decorative {
w.empty(tag, &attrs);
return;
}
w.open(tag, &attrs);
if let Some(rid) = link {
w.empty("a:hlinkClick", &[("r:id", rid)]);
}
if alt.decorative {
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
}
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
w.close(tag);
}
+5 -17
View File
@@ -220,18 +220,13 @@ impl Writer<'_> {
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
native.unwrap_or(&root),
);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
@@ -239,11 +234,7 @@ impl Writer<'_> {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
picture,
);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
@@ -275,9 +266,8 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("Picture {docpr}");
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
@@ -365,7 +355,6 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.shape_docpr(extra.id);
let link = Self::drawing_link(props, rels);
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
@@ -380,7 +369,7 @@ impl Writer<'_> {
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
@@ -400,9 +389,8 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
+6 -114
View File
@@ -94,49 +94,6 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
"?"
}
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
/// for Strict documents (#3548):
///
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
/// schema's name) and from Word's `wne:txbxContent`.
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
match prefix_for(uri, extra) {
"wp" => drawing_prefix(local, parent),
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
p => p,
}
}
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
match (parent, local) {
(_, "wsp") => "wps",
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
(_, "wpc") => "wpc",
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
(Some("wps:txbx"), "txbxContent") => "w",
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
_ => "wp",
}
}
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
fn requirement_prefix(uri: &[u8]) -> &'static str {
@@ -385,8 +342,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
loop {
buf.clear();
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
let el_ns = match &res {
ResolveResult::Bound(ns) => Some(ns.as_ref()),
let el_prefix = match &res {
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
_ => None,
};
match ev {
@@ -399,8 +356,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
return Err(DocxError::Limit("too many XML elements".into()));
}
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
let name = match el_ns {
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
let name = match el_prefix {
Some(p) => format!("{p}:{local}"),
None => local,
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
@@ -462,22 +419,15 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
attach(&mut stack, &mut root, el);
}
}
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
// `&#13;` is a character reference and stays.
Event::Text(t) => {
if let Some(top) = stack.last_mut() {
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
push_text(top, &s);
}
}
Event::CData(t) => {
if let Some(top) = stack.last_mut() {
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
let s = t
.xml_content(XmlVersion::Explicit1_0)
.map(|s| s.into_owned())
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
let s = String::from_utf8_lossy(&t).into_owned();
push_text(top, &s);
}
}
@@ -643,42 +593,6 @@ mod tests {
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
/// Every element name in `e`, depth first.
fn names(e: &El, out: &mut Vec<String>) {
out.push(e.name.clone());
for c in e.els() {
names(c, out);
}
}
#[test]
fn strict_drawing_names_read_as_word_2010_names() {
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
</a:graphicData></a:graphic></d:anchor>
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
assert_eq!(got.join(" "), want);
// The Transitional namespaces read as before.
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
@@ -731,28 +645,6 @@ mod tests {
assert_eq!(t.attr("xml:space"), Some("preserve"));
}
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
#[test]
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
let x =
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
}
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; `&#13;` stays a CR.
#[test]
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc&#13;d<![CDATA[e\r\nf]]></x:t></x:document>";
let e = parse(x).unwrap();
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
// The writer escapes a CR, so a CR in the text survives a save.
let x = format!(
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
esc("a\r\nb\rc")
);
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
}
#[test]
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
-61
View File
@@ -372,64 +372,3 @@ fn hostile_chunks_never_panic() {
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
}
/// A DOCX whose only picture names a part the package lacks (`media/gone.png`), or sits outside it.
fn inner_with_dropped_picture(external: bool) -> Vec<u8> {
inner_with_dropped_pictures(1, external)
}
/// As [`inner_with_dropped_picture`], with `n` pictures each naming its own missing part.
fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec<u8> {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect();
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p><w:sectPr/></w:body></w:document>"#);
let mode = if external { r#" TargetMode="External""# } else { "" };
let image = |i: usize| {
let target = if external { format!("http://127.0.0.1:9/never-fetched{i}.png") } else { format!("media/gone{i}.png") };
format!(
r#"<Relationship Id="rIdP{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/>"#
)
};
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#,
(0..n).map(image).collect::<String>()
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A picture the chunk's own reader dropped is the file's too (#6005), named by the chunk part.
#[test]
fn a_picture_dropped_inside_a_chunk_is_recorded_against_the_chunk() {
for (external, reason) in [(false, wordcraft_doc::MissingPictureReason::MissingPart), (true, wordcraft_doc::MissingPictureReason::ExternalTarget)]
{
let inner = inner_with_dropped_picture(external);
let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)));
let d = read(&bytes).unwrap();
let got: Vec<(&str, &str, _)> = d.missing_pictures.list.iter().map(|m| (m.part.as_str(), m.story.as_str(), m.reason)).collect();
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1, "word/afchunk.docx > word/document.xml");
assert_eq!(got[0].2, reason);
}
}
/// A chunk with more dropped pictures than the list holds still counts them all (#6005).
#[test]
fn a_chunks_total_of_dropped_pictures_counts_past_the_list() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
let inner = inner_with_dropped_pictures(n, false);
let d = read(&package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
assert_eq!(d.missing_pictures.total, n as u64);
}
-54
View File
@@ -342,57 +342,3 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
#[test]
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let content = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2"))
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
for pass in 0..2 {
let d = read(&bytes).unwrap();
bytes = write(&d).unwrap();
let xml = document_xml(&bytes);
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
#[test]
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
// not gain a Table of Contents w:sdt on save.
let bare = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
p(&t("Chapter One")),
);
let d_bare = read(&package(&bare)).unwrap();
let xml_bare = document_xml(&write(&d_bare).unwrap());
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
let sect_toc = format!(
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let d_sect = read(&package(&format!("{}{}", sdt(pr, &sect_toc), p(&t("Chapter One"))))).unwrap();
assert!(controls(&d_sect).is_empty());
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
let xml_sect = document_xml(&write(&d_sect).unwrap());
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
}
+3 -146
View File
@@ -1766,27 +1766,12 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
chartex_docx_linked(requires, requires_ns, false)
}
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
/// takes the chart's choice.
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
let other = if link {
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
} else {
""
};
let frame = format!(
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
);
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
let body = format!(
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
);
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
let types = format!(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
@@ -1842,25 +1827,6 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
}
}
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
/// and saving keeps it on both branches (the writer rebuilds their frames).
#[test]
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
for _ in 0..2 {
let p = d.body[0].as_para().unwrap();
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
let files = unzip(&wordcraft_docx::write(&d).unwrap());
let body = text(&files, "word/document.xml");
let ids = attr_values(&body, "a:hlinkClick", "r:id");
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
d = wordcraft_docx::read(&zip(&entries)).unwrap();
}
}
#[test]
fn resized_office_2016_chart_resizes_its_fallback_too() {
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
@@ -2496,31 +2462,6 @@ fn east_asian_number_formats_read_label_and_round_trip() {
assert_eq!(formats(&back), want);
}
/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that
/// text, as in Word, and keeps both through a save.
#[test]
fn a_none_level_s_literal_text_is_its_label() {
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::section::NumFormat;
let numbering = format!(
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="none"/><w:lvlText w:val="RESPONSE:"/><w:pPr><w:ind w:left="1440" w:hanging="1440"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let body = r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>X</w:t></w:r></w:p>"#;
let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
let label = |d: &Document| {
let n = paras(d).first().and_then(|p| p.props.numbering)?;
Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0)
};
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(label(&d).as_deref(), Some("RESPONSE:"));
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(
back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)),
Some((NumFormat::None, "RESPONSE:", 72.0, 72.0))
);
assert_eq!(label(&back).as_deref(), Some("RESPONSE:"));
}
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
/// the table's last cell) and is saved as a matched pair.
#[test]
@@ -3947,33 +3888,6 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
}
}
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
/// within the bound, so the family a real list would pick survives while a hostile list cannot
/// cost its whole length in every piece of text drawn in it.
#[test]
fn a_hostile_fallback_list_keeps_its_leading_families() {
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
let list = format!("{};Source Serif 4", candidates.join(";"));
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
let d = read_body(&body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
let kept = c.font.as_deref().expect("the leading families are kept");
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
}
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
#[test]
fn a_real_fallback_list_is_read_whole() {
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
let d = read_body(body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
}
/// The font table's family classes: Word draws a missing roman font in a serif face.
#[test]
fn roman_fonts_are_read_from_the_font_table() {
@@ -3983,15 +3897,6 @@ fn roman_fonts_are_read_from_the_font_table() {
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
}
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
#[test]
fn roman_fonts_split_fallback_lists() {
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
}
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
#[test]
fn html_auto_spacing_off_is_read_and_kept() {
@@ -4390,54 +4295,6 @@ fn zero_effects_stay_zero_rotated() {
}
}
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
/// its rotated bounds.
#[test]
fn bare_turns_are_kept_through_a_save() {
let pic = |effects: &str| {
format!(
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
)
};
let read =
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
let d = read(&pic(effects));
assert!(bare(&d), "{effects:?}");
let out = wordcraft_docx::write(&d).unwrap();
let body = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
}
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
assert!(!bare(&d));
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
assert!(!f.bare_turn, "turned here: the rotated bounds again");
// A shadow's room added later: saved, and so laid out, with the turn's room again.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.effect = [2.0; 4];
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.wrap = wordcraft_doc::para::Wrap::Square;
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
assert!(!bare(&read(&anchored)));
}
/// The math namespace, declared on the math element itself (the fixture root doesn't).
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
-20
View File
@@ -560,23 +560,3 @@ fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
}
/// A picture in a building block that names a part the package lacks is recorded against the
/// glossary part, not the main document (#6005).
#[test]
fn a_missing_picture_in_a_building_block_names_the_glossary_part() {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let part =
format!(r#"<w:docPart><w:docPartPr><w:name w:val="Logo"/></w:docPartPr><w:docPartBody><w:p>{drawing}</w:p></w:docPartBody></w:docPart>"#);
let bytes = package(
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
&[
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
("word/glossary/document.xml", glossary_xml(&part)),
("word/glossary/_rels/document.xml.rels", rels(&[("rIdPic", &format!("{REL}/image"), "media/gone.png")])),
],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let stories: Vec<&str> = d.missing_pictures.list.iter().map(|m| m.story.as_str()).collect();
assert_eq!(stories, ["word/glossary/document.xml"], "{:?}", d.missing_pictures.list);
}
-5
View File
@@ -21,11 +21,8 @@ mod heading_levels;
mod legacy_forms;
mod ligatures;
mod malformed;
mod missing_pictures;
mod nested_block_controls;
mod note_comments;
mod passthrough;
mod picture_links;
mod positional_tabs;
mod privacy;
mod roundtrip;
@@ -37,11 +34,9 @@ mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod twips_wrap;
mod undefined_style;
mod vml_shapes;
mod word_xml;
-149
View File
@@ -1,149 +0,0 @@
//! Pictures dropped at open are recorded for strict export (#6005): a missing part, an external
//! target and a link-only picture (ECMA-376 Part 1 §20.1.8.13 `a:blip` `r:embed`/`r:link`).
//! Original synthetic packages; nothing is fetched, the reader has no network code.
use std::io::Write;
use wordcraft_doc::{Document, InlineObject, MissingPictureReason};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:v="urn:schemas-microsoft-com:vml""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn blip(attrs: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip {attrs}/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A package: `body` in `word/document.xml` (whose relationships are `doc_rels`), an optional
/// header with `header` as its content (relationships `header_rels`) and `extra` parts.
fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&str, Vec<u8>)]) -> Vec<u8> {
let hdr_rel = if header.is_some() { format!(r#"<Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/>"#) } else { String::new() };
let sect = if header.is_some() { r#"<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"# } else { "<w:sectPr/>" };
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p>{sect}</w:body></w:document>"#);
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let drels = format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{doc_rels}{hdr_rel}</Relationships>"#);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut put = |name: &str, bytes: &[u8]| {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
};
put("[Content_Types].xml", types.as_bytes());
put("_rels/.rels", rels.as_bytes());
put("word/document.xml", doc.as_bytes());
put("word/_rels/document.xml.rels", drels.as_bytes());
if let Some((content, hrels)) = header {
put("word/header2.xml", format!(r#"<w:hdr {NS}><w:p>{content}</w:p></w:hdr>"#).as_bytes());
put(
"word/_rels/header2.xml.rels",
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{hrels}</Relationships>"#).as_bytes(),
);
}
for (n, b) in extra {
put(n, b);
}
zip.finish().unwrap().into_inner()
}
fn image_rel(id: &str, target: &str, external: bool) -> String {
let mode = if external { r#" TargetMode="External""# } else { "" };
format!(r#"<Relationship Id="{id}" Type="{REL}/image" Target="{target}"{mode}/>"#)
}
fn missing(d: &Document) -> Vec<(String, String, MissingPictureReason)> {
d.missing_pictures.list.iter().map(|m| (m.part.clone(), m.story.clone(), m.reason)).collect()
}
fn pictures(d: &Document) -> usize {
d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count()
}
#[test]
fn external_missing_and_link_only_pictures_are_recorded_and_still_dropped() {
let body = [blip(r#"r:embed="rIdExt""#), blip(r#"r:embed="rIdGone""#), blip(r#"r:link="rIdLink""#), blip(r#"r:embed="rIdNone""#)].concat();
let rels = [
image_rel("rIdExt", "http://127.0.0.1:9/never-fetched.png", true),
image_rel("rIdGone", "media/absent.png", false),
image_rel("rIdLink", "file:///C:/pics/linked.png", true),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(pictures(&d), 0, "nothing is drawn for them, as before");
assert_eq!(
missing(&d),
[
("http://127.0.0.1:9/never-fetched.png".into(), "word/document.xml".into(), MissingPictureReason::ExternalTarget),
("word/media/absent.png".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
("file:///C:/pics/linked.png".into(), "word/document.xml".into(), MissingPictureReason::LinkOnly),
("rIdNone".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
]
);
assert!(!d.missing_pictures.truncated);
}
#[test]
fn a_header_pictures_story_is_the_header_part() {
let rels = [image_rel("rIdHx", "https://example.invalid/logo.png", true), image_rel("rIdOk", "media/ok.png", false)].concat();
let header = [blip(r#"r:embed="rIdHx""#), blip(r#"r:embed="rIdOk""#)].concat();
let bytes = package("", "", Some((&header, &rels)), &[("word/media/ok.png", png())]);
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(missing(&d), [("https://example.invalid/logo.png".into(), "word/header2.xml".into(), MissingPictureReason::ExternalTarget)]);
assert_eq!(d.missing_pictures.main, "word/document.xml");
assert!(d.missing_pictures.part_names.values().any(|n| n == "word/header2.xml"));
assert_eq!(d.missing_pictures.media_parts.get("ok.png").map(String::as_str), Some("word/media/ok.png"));
}
#[test]
fn a_picture_that_is_there_is_not_recorded() {
let body = blip(r#"r:embed="rIdOk""#);
let d = wordcraft_docx::read(&package(&body, &image_rel("rIdOk", "media/ok.png", false), None, &[("word/media/ok.png", png())])).unwrap();
assert_eq!(pictures(&d), 1);
assert!(d.missing_pictures.list.is_empty());
}
#[test]
fn vml_image_data_links_are_recorded() {
let pict =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape id="s" style="width:50pt;height:50pt"><v:imagedata {attrs}/></v:shape></w:pict></w:r>"#);
let body = [pict(r#"r:id="rIdExt""#), pict(r#"r:href="rIdLink""#), pict(r#"r:id="rIdGone""#)].concat();
let rels = [
image_rel("rIdExt", "https://example.invalid/a.png", true),
image_rel("rIdLink", "https://example.invalid/b.png", true),
image_rel("rIdGone", "media/none.png", false),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
let got: Vec<_> = missing(&d).into_iter().map(|(p, _, r)| (p, r)).collect();
assert_eq!(
got,
[
("https://example.invalid/a.png".to_string(), MissingPictureReason::ExternalTarget),
("https://example.invalid/b.png".to_string(), MissingPictureReason::LinkOnly),
("word/media/none.png".to_string(), MissingPictureReason::MissingPart),
]
);
}
#[test]
fn the_list_is_capped_and_the_document_is_still_equal() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 5;
let body: String = (0..n).map(|i| blip(&format!(r#"r:embed="r{i}""#))).collect();
let rels: String = (0..n).map(|i| image_rel(&format!("r{i}"), &format!("media/gone{i}.png"), false)).collect();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
// Not part of the document: `==` and the clone ignore it, and a save doesn't carry it.
let mut e = d.clone();
e.missing_pictures = Default::default();
assert_eq!(d, e);
assert!(!serde_json::to_string(&d).unwrap().contains("gone"));
}
@@ -1,180 +0,0 @@
//! A body-level content control nested directly in another body-level one stays body-level when
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
use std::io::Write;
use wordcraft_doc::{ContentControl, Document, StoryRef};
use wordcraft_docx::{read, write};
const NS: &str =
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
fn package(body: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(docx: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
s
}
fn t(s: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
}
fn p(inner: &str) -> String {
format!("<w:p>{inner}</w:p>")
}
fn sdt(pr: &str, content: &str) -> String {
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
}
fn controls(d: &Document) -> Vec<ContentControl> {
let mut out = Vec::new();
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
out
}
/// How many `w:p` elements are open at byte `at` of `xml`.
fn open_paragraphs(xml: &str, at: usize) -> usize {
let head = &xml[..at];
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
opened - head.matches("</w:p>").count()
}
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
fn depth_of_control(xml: &str, marker: &str) -> usize {
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
let start = xml[..at].rfind("<w:sdt>").expect("control start");
open_paragraphs(xml, start)
}
fn round_trip(body: &str) -> (Document, Document, String) {
let d = read(&package(body)).unwrap();
let bytes = write(&d).unwrap();
let back = read(&bytes).unwrap();
(d, back, document_xml(&bytes))
}
fn repeating_section() -> String {
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
}
#[test]
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
let (_, _, xml) = round_trip(&repeating_section());
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
// The text control stays inside the paragraph, and the item holds that paragraph.
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
}
#[test]
fn a_repeating_section_round_trips_its_controls_and_text() {
let (d, back, _) = round_trip(&repeating_section());
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
assert_eq!(back.body.len(), d.body.len());
}
#[test]
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
}
#[test]
fn nested_block_controls_in_a_table_cell_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
}
#[test]
fn a_run_level_control_stays_inside_its_paragraph() {
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
}
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
/// the body-level `_GoBack` bookmark Word leaves after the last control.
fn bound_section_body(bound: bool, bookmark: bool) -> String {
let section_binding = if bound {
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
} else {
""
};
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
let field = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
if bound { bind("/a[1]/n[1]") } else { String::new() },
t("note 1")
);
let item = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
p(&format!("{field}{}", t(" ")))
);
let section = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
);
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
}
fn assert_section_block_level(body: &str) {
let (d, back, xml) = round_trip(body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
}
#[test]
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(true, true));
}
#[test]
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
assert_section_block_level(&bound_section_body(true, false));
}
#[test]
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(false, true));
}
#[test]
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
let (d, back, xml) = round_trip(&body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
// The bookmark is kept.
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
}
-127
View File
@@ -1,127 +0,0 @@
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
//! synthetic packages; the 2×2 picture is generated in code.
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
/// An inline picture's run; `link` goes into its `wp:docPr`.
fn picture_run(id: u32, link: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
fn package(body: &str) -> Vec<u8> {
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let png = png();
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", xml.as_bytes()),
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
("word/media/square.png", png.as_slice()),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// The link on each picture of the first paragraph, in order.
fn picture_links(doc: &Document) -> Vec<Option<String>> {
let p = doc.body[0].as_para().unwrap();
p.object_offsets()
.into_iter()
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
.map(|off| p.props_of_char(off).link.clone())
.collect()
}
fn part(bytes: &[u8], name: &str) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn a_link_on_a_pictures_drawing_is_read() {
let body = format!(
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
// Word's way: the link on the drawing.
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
// A click naming a relationship that is no hyperlink, and one naming none.
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
// A link around the run wins over the drawing's.
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
);
let doc = wordcraft_docx::read(&package(&body)).unwrap();
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
}
#[test]
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
let mut doc = Document::new();
doc.media.insert("square.png".into(), Arc::new(png()));
let picture = || InlineObject::Image {
media: "square.png".into(),
w: 20.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
let bytes = wordcraft_docx::write(&doc).unwrap();
let xml = part(&bytes, "word/document.xml");
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
assert_eq!(clicks.len(), 1, "{xml}");
let rid = clicks[0].split('"').nth(1).unwrap();
let docpr = xml.find("<wp:docPr ").unwrap();
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
let rels = part(&bytes, "word/_rels/document.xml.rels");
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
// The run's own hyperlink names the same relationship, so the address is stored once.
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
let back = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
// The drawing's link alone (as Word writes it) reads back the same.
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
assert_ne!(without_run_link, xml);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let name = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
if name == "word/document.xml" {
data = without_run_link.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
let edited = out.finish().unwrap().into_inner();
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
}
+2 -3
View File
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -195,7 +195,6 @@ fn every_para_prop_round_trips() {
suppress_hyphens: Some(true),
suppress_line_numbers: Some(true),
bidi: Some(false),
text_direction: Some(ParagraphTextDirection::TbRl),
drop_cap: None,
drop_cap_margin: None,
drop_cap_distance: None,
@@ -18,25 +18,6 @@ fn docx(body: &str) -> Vec<u8> {
zw.finish().unwrap().into_inner()
}
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
for (name, value) in [
("_rels/.rels", root.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/styles.xml", styles.as_bytes()),
] {
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
bytes.write_all(value).unwrap();
}
bytes.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
@@ -78,52 +59,3 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
}
#[test]
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
use wordcraft_doc::props::ParaProps;
use wordcraft_doc::props::ParagraphTextDirection;
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
let mut body: String =
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
for (para, value) in paras.iter().zip(values) {
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
}
assert_eq!(paras[values.len()].props.text_direction, None);
let json = serde_json::to_value(&paras[0].props).unwrap();
assert_eq!(json["textDirection"], "lrTb");
let decoded: ParaProps = serde_json::from_value(json).unwrap();
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
let first = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&first);
for value in values {
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
}
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
let twice = document_xml(&second);
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
for value in values {
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
}
}
#[test]
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
let mut styles_xml = String::new();
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
}
-182
View File
@@ -1,182 +0,0 @@
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
//! drawing is written once and filled in with either set of namespaces.
use std::io::{Read, Write};
use wordcraft_doc::{Document, InlineObject, PartKind};
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const TRANSITIONAL_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
/// text box content element.
struct Names {
ns: &'static str,
rels: &'static str,
shape: &'static str,
group: &'static str,
canvas: &'static str,
content: &'static str,
}
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
/// The Strict schema's own names: the text box content in `wp:` too.
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
/// The same drawings in a Transitional document.
const TRANSITIONAL: Names =
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn solid(rgb: &str) -> String {
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
}
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
let s = n.shape;
let connector = prst.contains("Connector");
let nv = if connector {
format!("<{s}:cNvCnPr/>")
} else if text.is_some() {
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
} else {
format!("<{s}:cNvSpPr/>")
};
let fill = if connector { String::new() } else { solid(fill) };
let line =
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
let txbx = match text {
Some(t) => {
let c = n.content;
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
}
None => String::new(),
};
format!(
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
)
}
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
}
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
/// shape and a group, each 1 in × ½ in.
fn package(n: &Names) -> Vec<u8> {
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
let full = (0, 0, 914_400, 457_200);
let half = (0, 0, 457_200, 457_200);
let g = n.group;
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
format!(
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
solid("F39C12")
)
};
let group = format!(
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
group_xfrm(full),
shape(n, "ellipse", half, "28B463", None),
group_xfrm((457_200, 0, 457_200, 457_200)),
shape(n, "triangle", half, "8E44AD", None),
);
let c = n.canvas;
let canvas = format!(
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
solid("FDF2E9"),
solid("A04000"),
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
group_xfrm((457_200, 45_720, 365_760, 365_760)),
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
);
let drawings = [
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
anchor(4, 5_029_200, WPG, &group),
anchor(5, 6_400_800, WPC, &canvas),
];
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
n.ns,
drawings.concat()
);
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn objects(d: &Document) -> Vec<InlineObject> {
d.body[0].as_para().unwrap().objects.clone()
}
/// The text of each text box story, in reading order.
fn box_texts(d: &Document) -> Vec<String> {
let mut ids = Vec::new();
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
ids.iter()
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn strict_shapes_read_like_their_transitional_twins() {
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
let want = objects(&transitional);
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
// nested group is flattened into it, and a canvas with its background.
assert_eq!(want.len(), 5, "{want:#?}");
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
assert!(
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
"{:?}",
want[4]
);
assert_eq!(box_texts(&transitional), ["Strict text box"]);
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
let strict = wordcraft_docx::read(&package(names)).unwrap();
assert_eq!(objects(&strict), want, "{name}");
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
}
}
#[test]
fn strict_shapes_are_saved_and_reopened() {
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
let saved = wordcraft_docx::write(&strict).unwrap();
let xml = document_xml(&saved);
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
assert!(xml.contains(name), "{name} missing from the saved document");
}
let reopened = wordcraft_docx::read(&saved).unwrap();
assert_eq!(objects(&reopened), objects(&strict));
assert_eq!(box_texts(&reopened), ["Strict text box"]);
}
+1 -12
View File
@@ -132,15 +132,6 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
// In a group, a member's margins go with that member's text, not an empty box's before it.
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
// Text direction (#5193), as Word for Windows reads it: `vertical` and
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
shape("", r#"style="layout-flow:vertical""#),
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
shape("", r#"style="layout-flow:vertical-ideographic""#),
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal-ideographic""#),
]
.concat();
let doc = format!(
@@ -149,15 +140,13 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
assert_eq!(bodies.len(), 12);
assert_eq!(bodies.len(), 6);
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
assert_eq!(bodies[2], TextBody::default());
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
assert_eq!(bodies[4].inset, None);
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
// Saved and read again, they stay.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
-1
View File
@@ -228,7 +228,6 @@ fn hyperlink_field_targets_follow_the_field_code() {
for (para, link) in [
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
(complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")),
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
(simple(r#"HYPERLINK \l &quot;Sec&quot; \m"#), Some("#Sec")),
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
-55
View File
@@ -1,55 +0,0 @@
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
//! Fixtures are hand-written.
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
use crate::fixtures::zip;
/// A package whose body is `body`.
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(body: &str) -> Vec<ParaProps> {
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn indents_beyond_16_bits_wrap() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
);
assert_eq!(p[0].indent_left, Some(36.0));
assert_eq!(p[1].indent_right, Some(72.0));
// 65556 -> 20 twips; -65516 -> 20 twips.
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
}
#[test]
fn spacing_beyond_16_bits_wraps() {
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
}
#[test]
fn in_range_and_unit_values_are_unchanged() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
);
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
}
-9
View File
@@ -270,12 +270,3 @@ fn flat_opc_limits_count_what_is_not_kept() {
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
}
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
#[test]
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
let d = read_xml(xml.as_bytes()).unwrap();
assert_eq!(paras(&d)[0].plain_text(), "a b");
}
-6
View File
@@ -323,7 +323,6 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult {
}
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
s.pending = None;
s.column = None;
s.column_mode = false;
sel_result(s)
@@ -333,7 +332,6 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
s.pending = None;
sel_result(s)
}
@@ -394,7 +392,6 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
s.pending = None;
}
sel_result(s)
}
@@ -489,9 +486,6 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
if count == n {
s.sel =
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
// replaces the selection takes its formatting.
s.pending = None;
return sel_result(s);
}
}
+27 -95
View File
@@ -94,9 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format), "strict"?: bool (a .pdf or .png only: refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save), "strict"?: bool (as file.save)}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@@ -114,13 +114,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character on the page can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.strictReport", "Strict Export Report", "File › Export", strict_report)
.params(r#"{"markup"?: bool (as file.exportPdf), "page"?: n (1-based: check that page of the page image file.exportPng writes; omit for every page of a PDF)} → {"schema": 1, "strict": "passed" | "failed", "missing_glyphs": [{"cp": "U+2D30", "count": n}], "missing_pictures": [{"part", "source_part": the story part it is in (word/header2.xml; background, body, header, footer, text box or "" where unknown), "reason": "missing_part" | "external_target" | "link_only" | "undecodable" | "unsupported_format"}], "truncated": bool (either list was cut at 256 entries), "total_missing_glyphs": n (distinct, past the cap too), "total_missing_pictures": n (past the cap too), "glyphs_truncated": bool, "pictures_truncated": bool}"#)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
@@ -256,8 +253,27 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
);
}
let password = p::str(v, "password");
// An empty file opens as a new blank document that saves back to it (#1384).
let (doc, encrypted) = read_document(s, path, v)?;
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
// blank document that saves back to it (#1384).
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
match s.used_because() {
None => s.set_document(doc),
Some(why) => {
@@ -278,53 +294,6 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
)
}
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are
/// in the editing language.
fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> {
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
Ok((doc, encrypted))
}
/// File › New from a template (#2852): a new, untitled document with the template's content,
/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`).
/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank
/// document gives way to it, as to a file `file.open` opens: that is how a template the platform
/// hands the app starts (#3433). The template is only read; the new document has no file, so
/// saving it asks where to write, and a protected template's password doesn't come along.
pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let (mut doc, _) = read_document(s, path, v)?;
doc.settings.attached_template = Some(path.to_string());
if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() {
s.set_document(doc);
} else {
s.add_document(doc);
}
s.dirty = true;
sel_result(s)
}
/// Close a document with no window to ask about it (agents, headless sessions).
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
let id = match v.get("id").filter(|x| !x.is_null()) {
@@ -372,7 +341,6 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"saved": false}));
}
};
check_strict_output(p::bool(v, "strict").unwrap_or(false), &path.to_string_lossy())?;
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
@@ -454,39 +422,9 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
comments: markup && s.prefs.markup.comments,
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
numeral: s.view.numeral.resolve(crate::session::system_locale()),
strict: p::bool(v, "strict").unwrap_or(false),
}
}
/// `strict: true` is for a PDF or a page image only: any other output is a parameter error.
fn check_strict_output(strict: bool, path: &str) -> Result<(), CmdError> {
if strict && !crate::strict::is_strict_output(path) {
return Err(CmdError::Params("`strict` applies to a .pdf or .png output only".into()));
}
Ok(())
}
/// The strict report of a PDF export of the document (every page), or with `page` of the page
/// image `file.exportPng` writes. Pure: it reads the document and writes nothing.
fn strict_report(s: &mut Session, v: &Value) -> CmdResult {
let opts = export_options(s, v);
let report = match p::u64(v, "page") {
Some(n) => {
let page = crate::p::page_index(n);
let l = s.export_layout_with(opts.markup);
if l.pages.get(page).is_none() {
return Err(CmdError::Params(format!("no page {}", page + 1)));
}
crate::strict::strict_report(&s.doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png)
}
None => {
let out = super::references::output_fields(&s.doc);
crate::strict::pdf_report(out.as_ref().unwrap_or(&s.doc), &opts)
}
};
Ok(report.to_json())
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
@@ -494,22 +432,16 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"exported": false}));
};
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
let strict = p::bool(v, "strict").unwrap_or(false);
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
// A page image is the printed page: headers and footers at full strength. Dimming them is
// the on-screen cue while editing the body, not part of the page.
let opts = wordcraft_render::RenderOptions { display: crate::strict::png_display(), ..Default::default() };
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
if strict {
let report = crate::strict::strict_report(&s.doc, &l, &opts.display, Some(page..=page), wordcraft_layout::StrictOutput::Png);
if !report.passed() {
return Err(CmdError::Failed(report.refusal()));
}
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
+1 -3
View File
@@ -198,9 +198,7 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
continue;
}
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
// codes above, and literal/regex/wildcard searches, keep exact character matching.
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
atoms.push(lit(c));
}
let filler = match (o.ignore_punct, o.ignore_space) {
(true, true) => r"[\p{P} \t\x{A0}]*",
-26
View File
@@ -212,32 +212,6 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
pub(crate) struct ParaPropTracker {
before: Vec<Revision>,
}
impl ParaPropTracker {
pub(crate) fn new(s: &Session) -> Self {
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
}
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return after.fmt_change.clone();
}
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
.map(|Replacement(change)| change)
.unwrap_or_else(|| after.fmt_change.clone())
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
-38
View File
@@ -63,17 +63,6 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
let end = s.doc.insert_text(&at, &text, &props)?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else if let Some(text) = retyped_text(s, v) {
// Text to display changed: the selection is retyped with it, then linked (as in Word).
crate::cmd::paste_text(s, &text)?;
let end = s.sel.focus.clone();
let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() };
s.doc.format_range(&start, &end, &|c| {
c.link = Some(url.clone());
c.style = style.clone();
})?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else {
let (a, b) = s.sel.ordered();
s.doc.format_range(&a, &b, &|c| {
@@ -84,33 +73,6 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The `text` to retype a selection with before linking it: only when it differs from the selected
/// text and both fit in one line of one paragraph without pictures or fields, outside any field's
/// result. Otherwise (as Word shows `<<Selection in Document>>`) the selection keeps its text.
fn retyped_text(s: &Session, v: &Value) -> Option<String> {
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
/// What can't stand in one line of text: breaks, and pictures or fields.
const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ];
let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?;
if text.contains(NOT_ONE_LINE) {
return None;
}
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path {
return None;
}
let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?;
if shown.contains(NOT_ONE_LINE) || shown == text {
return None;
}
// A selection in a field's result (between its markers, which it doesn't hold) is retyped by
// the next update of the field, not here.
if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) {
return None;
}
Some(text.to_string())
}
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
pub fn bookmark_name_ok(name: &str) -> bool {
+1 -32
View File
@@ -452,36 +452,6 @@ mod tests {
assert_eq!(numbered(&mut b), want);
}
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
#[test]
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
let mut s = Session::new(Document::new());
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() {
if i > 0 {
s.run("text.newParagraph", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": t})).unwrap();
}
s.run("select.all", &json!({})).unwrap();
let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]);
s.run("list.define", &json!({"levels": levels})).unwrap();
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
let want: Vec<Option<String>> =
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
assert_eq!(page, want);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let mut b = Session::new(back);
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
assert_eq!(again, want, "thaiNumbers survives .docx");
// In parentheses too.
let mut s = Session::new(Document::new());
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
}
/// #1793: the other heading presets of Word's List Library, level by level.
#[test]
fn every_heading_preset_numbers_three_heading_levels() {
@@ -489,8 +459,7 @@ mod tests {
("headings", [Some("1"), Some("1.1"), Some("1.1.1")]),
("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]),
("outlineHeadings", [Some("I."), Some("A."), Some("1.")]),
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
("chapter", [Some("Chapter 1"), None, None]),
] {
let mut s = headings("Heading3");
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
+3 -14
View File
@@ -76,11 +76,9 @@ pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
let lock = p::bool(v, "lockAspect").unwrap_or(true);
let (w0, h0) = obj_size(&o);
let (mut w, mut h) = (w0, h0);
let (mut w, mut h) = obj_size(&o);
let scale = p::f32(v, "scale");
let original = p::bool(v, "original").unwrap_or(false);
if original {
if p::bool(v, "original").unwrap_or(false) {
let (ow, oh) =
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
let k = scale.unwrap_or(100.0) / 100.0;
@@ -115,16 +113,7 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
(None, None) => true,
_ => lock,
};
// Sides set in proportion (from the original, or from a size whose sides both have a length)
// fit the limits together. Otherwise each side the call sets fits on its own, and a side it
// leaves as it was keeps its size, a straight line's 0 too.
let (w, h) = if keep && (original || (w0 > 0.0 && h0 > 0.0 && (w, h) != (w0, h0))) {
fit_size(w, h, true)
} else {
let (fw, fh) = fit_size(w, h, false);
let left = |new: f32, old: f32| !original && new == old;
(if left(w, w0) { w } else { fw }, if left(h, h0) { h } else { fh })
};
let (w, h) = fit_size(w, h, keep);
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
// SmartArt graphic lays itself out again at the new size.
let theme = s.doc.settings.theme_colors.clone();
+6 -18
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size)
.params(
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept, a side past 4000 pt shrinks the other with it)"#,
)
.when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
@@ -132,26 +132,14 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
.map(|p| (p.width, p.height))
.map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
with_obj(s, |o| {
if let InlineObject::Image { media, w, h, crop, .. } = o {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
// The replacement keeps the picture's width and takes its own aspect ratio, so a
// picture of another shape isn't stretched into the old frame. The old crop
// described the old picture, so it is dropped.
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
let nh = *w * px_h as f32 / px_w as f32;
if nh.is_finite() && nh > 0.0 {
*h = nh;
}
}
*crop = [0.0; 4];
}
})
})
-97
View File
@@ -77,43 +77,6 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
/// of the same physical row. A drop after the break must still stay on the destination page.
#[test]
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
for gap in [0.0, 6.0, 12.0] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
s.run("layout.break", &json!({"kind": "page"})).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
super::select::with_float(&mut s, |f| {
f.dist = gap;
f.dist_top = gap;
f.dist_bottom = gap;
})
.unwrap();
let before = serde_json::to_value(&s.doc.body).unwrap();
let depth = s.undo_depth();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
let hit = object_area(&mut s);
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
assert_eq!(s.undo_depth(), depth + 1);
let after = serde_json::to_value(&s.doc.body).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
let saved = object_area(&mut copy);
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
}
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
@@ -1071,66 +1034,6 @@ fn picture_size_past_the_limit_keeps_the_proportions() {
}
}
/// A side the call leaves as it was keeps its size, even outside 1–4000 pt: a straight line's 0
/// stays 0 when only its length is typed. Sides set in proportion (the original, or a size whose
/// sides both have a length) fit the limits together; any other side the call sets, on its own.
#[test]
fn picture_size_keeps_the_side_it_leaves() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "straightConnector", "width": 100.0, "height": 0.0})).unwrap();
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
// A side a scale sets on its own: 0 has no length to scale.
let scaled = |x: f32, k: f32| if x == 0.0 { 0.0 } else { (x * k).clamp(1.0, 4000.0) };
// Sizes with both sides follow each other: they fit 1–4000 pt by the one factor nearest 1, worked out by hand
// for the calls `width` locked, `height` locked, `scale` 50 and `scale` 200 with a width (in that order).
let states = [
((100.0, 0.0), None),
((0.0, 100.0), None),
((5000.0, 0.0), None),
((0.0, 5000.0), None),
((100.0, 0.5), Some([(200.0, 1.0), (4000.0, 20.0), (200.0, 1.0), (200.0, 1.0)])),
((5000.0, 50.0), Some([(200.0, 2.0), (4000.0, 40.0), (2500.0, 25.0), (200.0, 2.0)])),
((100.0, 50.0), Some([(200.0, 100.0), (400.0, 200.0), (50.0, 25.0), (200.0, 100.0)])),
];
for ((w0, h0), together) in states {
let follow = |i: usize, alone: (f32, f32)| together.map_or(alone, |t: [(f32, f32); 4]| t[i]);
let cases = [
(json!({"width": 200.0, "lockAspect": false}), (200.0, h0)),
(json!({"height": 200.0, "lockAspect": false}), (w0, 200.0)),
(json!({"width": 200.0}), follow(0, (200.0, h0))),
(json!({"height": 200.0}), follow(1, (w0, 200.0))),
(json!({"scale": 50}), follow(2, (scaled(w0, 0.5), scaled(h0, 0.5)))),
(json!({"scale": 200, "width": 200.0}), follow(3, (200.0, scaled(h0, 2.0)))),
(json!({"scale": 100}), (w0, h0)),
(json!({}), (w0, h0)),
// The Size dialog: both sides sent, the height as it was.
(json!({"width": 200.0, "height": h0, "lockAspect": false}), (200.0, h0)),
(json!({"width": 200.0, "height": 300.0}), (200.0, 300.0)),
];
for (v, (ew, eh)) in cases {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &v).unwrap();
let (w, h) = size(&s);
assert!((w - ew).abs() < 1e-3 && (h - eh).abs() < 1e-3, "{w0}×{h0} {v}: {w}×{h}, not {ew}×{eh}");
}
}
// Both sides of a picture's original size fit together, whatever size it had, even when the
// height comes out as it was: 4050×1012.5 pt is 4000×1000, not 4000×1012.5.
let data = super::super::insert::base64_encode(&png(400, 100));
s.run("insert.picture", &json!({"data": data})).unwrap();
for (w0, h0) in [(3000.0, 1012.5), (4050.0, 0.0), (100.0, 100.0)] {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 1350})).unwrap();
let (w, h) = size(&s);
assert!((w - 4000.0).abs() < 0.01 && (h - 1000.0).abs() < 0.01, "{w0}×{h0}: {w}×{h}");
}
// A height the original sets is set, though it is the one the picture had: 300×75 pt at
// 5400 % is 16200×4050, so with a width of 200 the height fits to 4000.
with_obj(&mut s, |o| o.set_size(100.0, 4050.0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 5400, "width": 200.0, "lockAspect": false})).unwrap();
assert_eq!(size(&s), (200.0, 4000.0));
}
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
#[test]
fn picture_crop_with_size_is_one_undo_step() {
+1 -52
View File
@@ -16,7 +16,6 @@ mod lists;
mod outline;
mod styles;
mod tabs_borders;
mod tools;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{cur as selection_para, fmt, indent, outdent};
@@ -27,10 +26,6 @@ use outline::{move_paras, outline_step, sort};
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
pub use tabs_borders::border_sides;
use tabs_borders::{borders, tabs};
use tools::{
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
trim_leading,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -188,17 +183,9 @@ pub fn specs() -> Vec<CommandSpec> {
.key(if cfg!(target_os = "macos") { "Ctrl+Shift+Right" } else { "Alt+Shift+Right" }),
CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
// Text selected within one paragraph is shaded itself, as in Word, so No Color takes
// off a shading a file gave the text (#4685); a caret or paragraphs shade paragraphs.
// `paragraph` (Borders and Shading › Apply to: Paragraph) shades the paragraphs anyway.
if p::bool(v, "paragraph") != Some(true) && shades_text(s) {
return super::format::apply(s, &|x| x.shading = c);
}
fmt(s, &|p| p.shading = c)
})
.params(
r#"{"color": "RRGGBB" | null, "paragraph"?: bool} (text selected inside one paragraph is shaded itself, as in Word; `paragraph: true` shades the paragraphs instead, as Borders and Shading › Apply to: Paragraph)"#,
),
.params(r#"{"color": "RRGGBB" | null}"#),
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params(
r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#,
),
@@ -275,43 +262,5 @@ pub fn specs() -> Vec<CommandSpec> {
}
sel_result(s)
}),
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
// selection, or the whole document with `scope: "all"`. One undo step per command.
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
]
}
/// Whether Shading applies to the selected text rather than to paragraphs: a selection inside one
/// paragraph, short of its mark. A selection reaching past the mark ends at the next paragraph's
/// start, so all of a paragraph's text is still text, except in the last paragraph of its story,
/// cell or box, where it stands for the paragraph with its mark. A selected picture, shape or text
/// box is its object character, not text (a field's object character is).
fn shades_text(s: &Session) -> bool {
let (a, b) = s.sel.ordered();
if a == b || a.story != b.story || a.path != b.path {
return false;
}
let Some(p) = s.doc.para_at(&a) else { return false };
if super::objects::object_selection(s).is_some() {
return false;
}
let last = s.doc.next_para(a.story, &a.path).is_none_or(|q| q.parent() != a.path.parent());
!(last && a.off == 0 && b.off >= p.len())
}
-332
View File
@@ -1,332 +0,0 @@
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
use serde_json::{Value, json};
use wordcraft_doc::para::LINE_BREAK;
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
use super::{CmdError, CmdResult, Session, p, sel_result};
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
/// command can add, stay bounded whatever the document says.
const MAX_TOOL_PARAS: usize = 10_000;
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
const MAX_INDENT_SPACES: usize = 1_000;
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
/// `all` selects the whole document first, so the command, its undo step and the result all
/// follow the full selection like any other command.
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
match p::str(v, "scope").unwrap_or("selection") {
"selection" => Ok(()),
"all" => {
s.run("select.all", &json!({"whole": true}))?;
Ok(())
}
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
}
}
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
/// selection offsets in a selected paragraph.
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
let ranges: Vec<(Pos, Pos)> = paths
.iter()
.filter_map(|path| {
let len = s.doc.para(story, path)?.len();
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
})
.collect();
for (a, b) in ranges {
crate::cmd::controls::check_edit(s, &a, &b)?;
}
Ok(())
}
/// The paragraphs a command runs over, in document order, and the story they are in.
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
let (a, b) = s.sel.ordered();
let paths = s.doc.para_paths_in(&a, &b);
if paths.is_empty() {
return Err(CmdError::Failed("no paragraphs selected".into()));
}
if paths.len() > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
}
Ok((a.story, paths))
}
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
let (a, b) = (a.min(b).min(len), b.min(len));
if a >= b {
return Ok(());
}
let at = |off: usize| Pos { story, path: path.clone(), off };
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
if s.doc.settings.track_changes {
track_delete(s, &at(a), &at(b))?;
} else {
s.doc.delete_range(&at(a), &at(b))?;
}
Ok(())
}
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
/// last first so the earlier offsets stay valid.
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
ranges.sort();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in ranges {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
for (a, b) in merged.into_iter().rev() {
delete_in_para(s, story, path, a, b)?;
}
Ok(())
}
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
/// count goes through this.
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
}
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
}
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
if breaks > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
}
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
for (i, _) in break_ranges(para).into_iter().rev() {
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
// The break stays at the head of the new paragraph; the paragraph break replaces it.
delete_in_para(s, story, &new.path, 0, 1)?;
}
}
sel_result(s)
}
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
/// adds one blank paragraph after the last selected paragraph too.
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
let after = p::bool(v, "after").unwrap_or(false);
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let count = paths.len().saturating_sub(1) + usize::from(after);
if count > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
}
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
if after {
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: last.clone(), off: len })?;
}
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: path.clone(), off: len })?;
}
sel_result(s)
}
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
/// blank line between them.
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
}
/// `para.removeBlank`: delete every empty paragraph in the selection.
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
// Last first: removing a block leaves the paths before it as they are.
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
// A paragraph must be empty, carry no section break and have a paragraph after it in
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
// before a table can't be joined across it.
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
continue;
}
let next = path.with_last(path.last() + 1);
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
crate::cmd::controls::check_delete(s, &a, &b)?;
if s.doc.settings.track_changes {
track_delete(s, &a, &b)?;
} else {
s.doc.remove_block(story, path)?;
}
}
sel_result(s)
}
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
/// `trimLeadingSpaces` deletes).
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
para.text.chars().take_while(|c| *c == ' ').count()
}
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
}
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let n = leading_spaces(para);
apply_deletions(s, story, &path, vec![(0, n)])?;
}
sel_result(s)
}
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
// paragraph's start stays; a run inside the text collapses to its last space, and one
// that reaches the paragraph's end to its first.
let mut runs = Vec::new();
let mut run: Vec<usize> = Vec::new();
let mut leading = true;
for (i, c) in live_chars(para) {
if c == ' ' {
run.push(i);
continue;
}
if !leading && run.len() > 1 {
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
}
run.clear();
leading = false;
}
if !leading && run.len() > 1 {
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
}
apply_deletions(s, story, &path, runs)?;
}
sel_result(s)
}
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
/// per space (Word's point-based indent from typing spaces), and are deleted.
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let live = live_leading_spaces(para);
let spaces = live.len();
if spaces == 0 {
continue;
}
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
if represented == 0 {
continue;
}
let before = para.props.clone();
let mut after = before.clone();
after.indent_first = Some(current + represented as f32 * 72.0);
after.cancel_char_indents(false, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
{
let p = s.doc.para_mut(story, &path)?;
p.props = after;
p.touch();
}
// A space is one byte; deleted spaces in between stay as they are.
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
}
sel_result(s)
}
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
continue;
}
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
props.fmt_change = None;
props.del = None;
if s.doc.settings.track_changes && n > 0 {
props.ins = Some(new_revision(s, RevisionKind::Insert));
} else {
props.ins = None;
}
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
let mut after = before.clone();
after.indent_first = Some(0.0);
after.indent_left = Some(0.0);
after.cancel_char_indents(true, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
let p = s.doc.para_mut(story, &path)?;
if n > 0 {
p.insert_text(0, &" ".repeat(n), &props)?;
}
p.props = after;
p.touch();
}
sel_result(s)
}
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
/// join into one paragraph.
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
apply_deletions(s, story, &path, break_ranges(para))?;
}
sel_result(s)
}
+3 -4
View File
@@ -103,9 +103,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// A field instruction split into its name, positional arguments and switches (ECMA-376
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word:
/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its
/// argument unless that word is another switch.
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
/// takes the word after it as its argument unless that word is another switch.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FieldCode {
pub name: String,
@@ -127,7 +126,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if chars.peek().is_some() => w.extend(chars.next()),
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
c => w.push(c),
}
}
+4 -13
View File
@@ -102,9 +102,7 @@ struct SeqCount {
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut count = SeqCount::default();
for path in doc.para_paths(StoryRef::Body) {
@@ -122,11 +120,7 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
let mut deleted = p.deleted_ranges().into_iter().peekable();
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
while deleted.next_if(|r| r.end <= off).is_some() {}
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
for (k, o) in p.objects.iter().enumerate() {
for id in o.text_boxes() {
if count.boxes.insert(id) {
let part = StoryRef::Part(id);
@@ -137,9 +131,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
}
}
}
let InlineObject::Field { instr, locked, .. } = o else { continue };
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
if f.name != "SEQ" || in_deletion {
if f.name != "SEQ" {
continue;
}
let Some(id) = f.args.first() else { continue };
@@ -155,9 +149,6 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
if *locked {
continue;
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
count.out.push((story, path.clone(), k, r));
+1 -120
View File
@@ -1,4 +1,3 @@
use super::properties::{new_property_value, text_result};
use super::*;
fn fields(s: &Session) -> Vec<(String, String)> {
@@ -447,7 +446,7 @@ fn update_fields_apply_the_capitalization_switches() {
"seventh",
"Seventh",
"BUDGET",
"new * Upper"
"new \\* Upper"
]
);
}
@@ -549,85 +548,6 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// A fresh scratch directory for the file-name tests below.
#[cfg(not(target_arch = "wasm32"))]
fn filename_scratch_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("wordcraft-filename-field-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// #3796: after Save As to a new name, Update Field with the caret in a header shows the new
/// name for FILENAME and the new full path for FILENAME \p.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_fields_in_a_header_follow_save_as() {
let dir = filename_scratch_dir("save-as");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME", r"FILENAME \p"]);
let first = dir.join("report_v1.docx");
let second = dir.join("report_v2.docx");
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got[0].1, "report_v1.docx");
assert_eq!(got[1].1, first.to_string_lossy());
s.run("file.saveAs", &json!({"path": second.to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got[0].1, "report_v2.docx");
assert_eq!(got[1].1, second.to_string_lossy());
let _ = std::fs::remove_dir_all(&dir);
}
/// #3796: a Word-style `FILENAME \* MERGEFORMAT` field whose cached text names the old file
/// shows the new name after Save As and Update Field.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_mergeformat_field_replaces_its_cached_name_after_save_as() {
let dir = filename_scratch_dir("mergeformat");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &[r"FILENAME \* MERGEFORMAT"]);
let first = dir.join("report_v1.docx");
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
// The cached text a file written earlier would carry.
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
if let Some(InlineObject::Field { result, .. }) = s.doc.para_mut(StoryRef::Part(hdr), &path).unwrap().objects.first_mut() {
*result = "report_v1.docx".into();
}
s.run("file.saveAs", &json!({"path": dir.join("report_v2.docx").to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v2.docx");
let _ = std::fs::remove_dir_all(&dir);
}
/// #3796: renaming the document (File > Info) changes what FILENAME shows on the next Update
/// Field; the extension stays.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_field_in_a_header_follows_rename() {
let dir = filename_scratch_dir("rename");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME"]);
s.run("file.save", &json!({"path": dir.join("report_v1.docx").to_string_lossy()})).unwrap();
s.run("file.rename", &json!({"name": "report_v3"})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v3.docx");
let _ = std::fs::remove_dir_all(&dir);
}
/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an
/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).
#[test]
fn quoted_field_argument_backslash_escapes() {
for (instr, shown) in [(r#"TITLE "x \* Upper""#, "x * Upper"), (r#"TITLE "x \\* Upper""#, r"x \* Upper"), (r#"TITLE "a \"b\" c""#, r#"a "b" c"#)]
{
let value = new_property_value(instr).unwrap();
assert_eq!(value, shown, "{instr}");
assert_eq!(text_result(instr, value), shown, "{instr}");
}
assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
@@ -793,45 +713,6 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
assert_eq!(results, expected);
}
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
#[test]
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
for instr in fields_in {
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let top = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
for (k, o) in para.objects.iter_mut().enumerate() {
if let InlineObject::Field { result, locked, .. } = o {
*locked = matches!(k, 4 | 6);
*result = if *locked { "7" } else { "9" }.into();
}
}
para.touch();
// The second Del field is a tracked deletion.
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
let out = output_fields(&s.doc).expect("the stale results change");
let shown: Vec<String> = out
.para(StoryRef::Body, &top)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
+5 -14
View File
@@ -54,20 +54,11 @@ fn resolve_para(
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
// mark carries a section break goes whole too: its section joins the following one, as when
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
if (del.is_some() && accept) || (ins.is_some() && !accept) {
let sectioned = story == StoryRef::Body
&& path.0.len() == 1
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
&& crate::cmd::has_next_para(s, story, path);
if sectioned {
s.doc.remove_section_break(path.last());
}
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
return Ok(());
}
// of a story or cell can't go, so it just stops being a revision.
if ((del.is_some() && accept) || (ins.is_some() && !accept))
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
{
return Ok(());
}
let para = s.doc.para_mut(story, path)?;
para.mark.ins = None;
+1 -1
View File
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)),
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
+1 -8
View File
@@ -8,18 +8,11 @@ use super::{
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
let column = match v.get("column") {
None => 0,
Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?,
};
let col = p::u64(v, "column").unwrap_or(0) as usize;
let desc = p::bool(v, "descending").unwrap_or(false);
let header = p::bool(v, "header");
let (tp, _, _) = cell(s)?;
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
let col = usize::try_from(column)
.ok()
.filter(|&col| t.rows.iter().any(|row| col < row.cells.len()))
.ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?;
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));

Some files were not shown because too many files have changed in this diff Show More