Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	crates/docx/Cargo.toml
This commit is contained in:
Dylan M. Taylor
2026-11-12 02:33:03 +00:00
63 changed files with 6851 additions and 5315 deletions
Generated
+18
View File
@@ -1065,6 +1065,8 @@ dependencies = [
"base64 0.23.1",
"chrono",
"craft-cfb",
"craft-drawingml",
"craft-math",
"craft-offcrypto",
"craft-opc",
"craft-wordmodel",
@@ -1657,6 +1659,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-pptx"
version = "0.1.0"
dependencies = [
"craft-drawingml",
"kurbo 0.13.1",
"serde",
"serde_json",
]
[[package]]
name = "craft-prc"
version = "0.1.0"
@@ -1711,6 +1723,7 @@ dependencies = [
"encoding_rs",
"flate2",
"proptest",
"rayon",
"serde",
"thiserror 2.0.21",
]
@@ -1765,7 +1778,9 @@ name = "craft-rtf"
version = "0.1.0"
dependencies = [
"craft-codepage",
"craft-mime",
"encoding_rs",
"proptest",
]
[[package]]
@@ -1872,6 +1887,7 @@ dependencies = [
"craft-xml",
"proptest",
"serde",
"serde_json",
]
[[package]]
@@ -1991,6 +2007,7 @@ dependencies = [
name = "craft-webp"
version = "0.1.0"
dependencies = [
"craft-video",
"image",
"thiserror 2.0.21",
]
@@ -2018,6 +2035,7 @@ name = "craft-wordmodel"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"craft-math",
"log",
"proptest",
"serde",
+1
View File
@@ -87,6 +87,7 @@ craft-metafile = { path = "crates/metafile" }
craft-offcrypto = { path = "crates/offcrypto" }
craft-opc = { path = "crates/opc" }
craft-ora = { path = "crates/ora" }
craft-pptx = { path = "crates/pptx" }
craft-penpot = { path = "crates/penpot" }
craft-pcraft-format = { path = "crates/pcraft-format" }
craft-plotter = { path = "crates/plotter" }
+3
View File
@@ -262,6 +262,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-plotter`](crates/plotter/README.md): cutter and pen-plotter command languages: HP-GL,
DMPL and GP-GL (plain and the Silhouette dialect, three generations, registration marks) from a
job in millimetres, plus the Silhouette device queries and reply parsers. No I/O.
- [`craft-pptx`](crates/pptx/README.md): PresentationML, the model of a `.pptx` presentation:
masters, layouts, slides, shapes, placeholders and their inheritance, transitions and animations,
comments, sections, media and kept parts. From DeckCraft. DrawingML comes from `craft-drawingml`.
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
duplex/copies/colour and driver preferences. PDF generation stays in each app.
- [`craft-proof`](crates/proof/README.md): proofing per language (spelling, suggestions, grammar,
+1
View File
@@ -12,6 +12,7 @@ workspace = true
[dependencies]
craft-drawingml = { workspace = true }
craft-math = { workspace = true }
craft-offcrypto = { workspace = true }
craft-opc = { workspace = true }
craft-wordmodel = { workspace = true }
+2 -1
View File
@@ -42,8 +42,9 @@ written back as it was read.
`write(doc)`, `write_as(doc, Flavor)` for `.docx`, `.docm`, `.dotx` and `.dotm`; `write_flat`
and `write_wordml2003` for the XML forms; `encrypt` for a password.
Equations (OMML) are read and written by `craft_math::omml`.
## Not here yet
- DrawingML (`a:`) shape fills, lines, custom geometry and colours are still read with this
crate's own code; they move to `craft-drawingml`.
- Equations (OMML) are read and written by this crate; they move to `craft-math`.
+26 -481
View File
@@ -1,496 +1,41 @@
//! Office Math (OMML, ECMA-376 Part 1 §22.1) → [`craft_wordmodel::math`] tree.
//! Office Math (OMML, ECMA-376 Part 1 §22.1) read with `craft_math::omml`.
use craft_wordmodel::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
};
use craft_wordmodel::math::{Math, MathJc, MathProps};
use craft_wordmodel::props::Rgb;
use craft_wordmodel::symbol_fonts;
use crate::xml::El;
use crate::xml::{El, Node};
/// An `m:oMath` element as an equation (structure plus its source XML).
pub fn read_omath(e: &El, jc: MathJc) -> Math {
Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false }
}
/// A colour every math run shares, taken off the runs: it is the equation's own colour, as
/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML
/// is kept: the writer replaces it only when the colour changes.
pub fn lift_color(math: &mut Math) -> Option<Rgb> {
let (mut shared, mut uniform) = (None, true);
craft_wordmodel::math_tree::each_run_mut(&mut math.nodes, &mut |r| match shared {
None => shared = Some(r.color),
Some(c) => uniform &= c == r.color,
});
let c = shared.flatten().filter(|_| uniform)?;
craft_wordmodel::math_tree::each_run_mut(&mut math.nodes, &mut |r| r.color = None);
Some(c)
}
/// `m:oMathParaPr/m:jc`.
pub fn para_jc(para: &El) -> MathJc {
match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) {
Some("left") => MathJc::Left,
Some("right") => MathJc::Right,
Some("center") => MathJc::Center,
_ => MathJc::CenterGroup,
}
}
/// The settings part's `m:mathPr`: how display equations wrap.
pub fn read_math_pr(e: &El) -> MathProps {
let mut m = MathProps::default();
let pr = Some(e);
m.brk_bin = match val(pr, "m:brkBin") {
Some("after") => BrkBin::After,
Some("repeat") => BrkBin::Repeat,
_ => BrkBin::Before,
};
m.brk_bin_sub = match val(pr, "m:brkBinSub") {
Some("-+") => BrkBinSub::MinusPlus,
Some("+-") => BrkBinSub::PlusMinus,
_ => BrkBinSub::MinusMinus,
};
if let Some(v) = e.child("m:wrapIndent").and_then(|c| crate::units::tw(c, "m:val")).filter(|v| v.is_finite() && *v >= 0.0) {
m.wrap_indent = v.min(1584.0);
}
m.wrap_right = prop(pr, "m:wrapRight", false);
m
}
/// OMML on/off property: absent `m:val` = on.
fn on(e: &El) -> bool {
match e.attr("m:val") {
None => true,
Some(v) => !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE"),
}
}
/// An on/off property `name` in the properties element `pr` (absent = `default`).
fn prop(pr: Option<&El>, name: &str, default: bool) -> bool {
pr.and_then(|p| p.child(name)).map(on).unwrap_or(default)
}
/// A character property (`m:chr`, `m:begChr`…): `None` when absent, `Some(None)` when empty.
fn chr(pr: Option<&El>, name: &str) -> Option<Option<char>> {
let c = pr?.child(name)?;
match c.attr("m:val") {
Some(v) => Some(v.chars().next()),
None => Some(None),
}
}
fn val<'a>(pr: Option<&'a El>, name: &str) -> Option<&'a str> {
pr.and_then(|p| p.child(name)).and_then(|c| c.attr("m:val"))
}
/// The argument held by child `name` of `e`.
fn sub_arg(e: &El, name: &str, depth: usize) -> Arg {
e.child(name).map(|c| arg(c, depth + 1)).unwrap_or_default()
}
/// Read the math content of an element (`m:oMath`, `m:e`, `m:num`…).
fn arg(e: &El, depth: usize) -> Arg {
let mut out = Vec::new();
if depth > MAX_DEPTH {
return out;
}
for k in e.els() {
node(k, depth, &mut out);
/// This reader's tree as `craft-xml`'s, which `craft_math::omml` reads. Both use the canonical
/// prefixes (`m:`, `w:`, `mc:`).
// shortcut: copies the equation's subtree; goes when the reader reads craft_xml::El directly.
fn to_craft(e: &El) -> craft_xml::El {
let mut out = craft_xml::El { name: e.name.clone(), attrs: e.attrs.clone(), children: Vec::with_capacity(e.kids.len()) };
for k in &e.kids {
out.children.push(match k {
Node::El(c) => craft_xml::Node::El(to_craft(c)),
Node::Text(t) => craft_xml::Node::Text(t.clone()),
});
}
out
}
fn node(k: &El, depth: usize, out: &mut Arg) {
let d = depth + 1;
match k.name.as_str() {
"m:r" => {
if let Some(r) = run(k) {
out.push(MNode::Run(r));
}
}
// Word puts ordinary runs in math zones for normal text in some files.
"w:r" => {
let text = run_text(k);
if !text.is_empty() {
let mut r = MRun { text, nor: true, sty: Some(MSty::Plain), ..Default::default() };
wrpr(k.child("w:rPr"), &mut r);
out.push(MNode::Run(r));
}
}
"m:f" => {
let pr = k.child("m:fPr");
let kind = match val(pr, "m:type") {
Some("skw") => FracKind::Skewed,
Some("lin") => FracKind::Linear,
Some("noBar") => FracKind::NoBar,
_ => FracKind::Bar,
};
out.push(MNode::Frac { kind, num: sub_arg(k, "m:num", d), den: sub_arg(k, "m:den", d) });
}
"m:sSup" => out.push(MNode::Script { kind: ScriptKind::Sup, base: sub_arg(k, "m:e", d), sub: Vec::new(), sup: sub_arg(k, "m:sup", d) }),
"m:sSub" => out.push(MNode::Script { kind: ScriptKind::Sub, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: Vec::new() }),
"m:sSubSup" => {
out.push(MNode::Script { kind: ScriptKind::SubSup, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) })
}
"m:sPre" => {
out.push(MNode::Script { kind: ScriptKind::Pre, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) })
}
"m:rad" => {
let pr = k.child("m:radPr");
out.push(MNode::Rad { deg: sub_arg(k, "m:deg", d), deg_hide: prop(pr, "m:degHide", false), e: sub_arg(k, "m:e", d) });
}
"m:nary" => {
let pr = k.child("m:naryPr");
let chr = chr(pr, "m:chr").map(|c| c.unwrap_or('∫')).unwrap_or('∫');
let lim_loc = match val(pr, "m:limLoc") {
Some("undOvr") => Some(LimLoc::UndOvr),
Some("subSup") => Some(LimLoc::SubSup),
_ => None,
};
out.push(MNode::Nary {
chr,
lim_loc,
grow: prop(pr, "m:grow", false),
sub_hide: prop(pr, "m:subHide", false),
sup_hide: prop(pr, "m:supHide", false),
sub: sub_arg(k, "m:sub", d),
sup: sub_arg(k, "m:sup", d),
e: sub_arg(k, "m:e", d),
});
}
"m:d" => {
let pr = k.child("m:dPr");
let beg = chr(pr, "m:begChr").unwrap_or(Some('('));
let end = chr(pr, "m:endChr").unwrap_or(Some(')'));
let sep = chr(pr, "m:sepChr").unwrap_or(Some('|'));
let elems: Vec<Arg> = k.children("m:e").map(|e| arg(e, d)).collect();
out.push(MNode::Delim {
beg,
end,
sep,
grow: prop(pr, "m:grow", true),
shp_match: val(pr, "m:shp") == Some("match"),
elems: if elems.is_empty() { vec![Vec::new()] } else { elems },
});
}
"m:func" => out.push(MNode::Func { name: sub_arg(k, "m:fName", d), e: sub_arg(k, "m:e", d) }),
"m:limLow" => out.push(MNode::Lim { upper: false, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }),
"m:limUpp" => out.push(MNode::Lim { upper: true, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }),
"m:acc" => {
let c = chr(k.child("m:accPr"), "m:chr").map(|c| c.unwrap_or('\u{302}')).unwrap_or('\u{302}');
out.push(MNode::Acc { chr: c, e: sub_arg(k, "m:e", d) });
}
"m:bar" => out.push(MNode::Bar { top: val(k.child("m:barPr"), "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) }),
"m:borderBox" => {
let pr = k.child("m:borderBoxPr");
out.push(MNode::BorderBox {
hide: [prop(pr, "m:hideTop", false), prop(pr, "m:hideBot", false), prop(pr, "m:hideLeft", false), prop(pr, "m:hideRight", false)],
strike: [
prop(pr, "m:strikeH", false),
prop(pr, "m:strikeV", false),
prop(pr, "m:strikeBLTR", false),
prop(pr, "m:strikeTLBR", false),
],
e: sub_arg(k, "m:e", d),
});
}
"m:box" => out.push(MNode::Boxed { e: sub_arg(k, "m:e", d) }),
"m:groupChr" => {
let pr = k.child("m:groupChrPr");
let c = chr(pr, "m:chr").map(|c| c.unwrap_or('\u{23DF}')).unwrap_or('\u{23DF}');
out.push(MNode::GroupChr { chr: c, top: val(pr, "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) });
}
"m:eqArr" => out.push(MNode::EqArr { rows: k.children("m:e").map(|e| arg(e, d)).collect() }),
"m:m" => {
let mut col_jc = Vec::new();
if let Some(mcs) = k.child("m:mPr").and_then(|p| p.child("m:mcs")) {
for mc in mcs.children("m:mc") {
let pr = mc.child("m:mcPr");
let jc = match val(pr, "m:mcJc") {
Some("left") => ColJc::Left,
Some("right") => ColJc::Right,
_ => ColJc::Center,
};
let count = val(pr, "m:count").and_then(|c| c.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 64);
for _ in 0..count {
if col_jc.len() < 64 {
col_jc.push(jc);
}
}
}
}
let rows = k.children("m:mr").take(1000).map(|r| r.children("m:e").take(64).map(|e| arg(e, d)).collect()).collect();
out.push(MNode::Matrix { rows, col_jc });
}
"m:phant" => {
let pr = k.child("m:phantPr");
out.push(MNode::Phant {
show: prop(pr, "m:show", true),
zero_wid: prop(pr, "m:zeroWid", false),
zero_asc: prop(pr, "m:zeroAsc", false),
zero_desc: prop(pr, "m:zeroDesc", false),
e: sub_arg(k, "m:e", d),
});
}
// Tracked insertions show; deletions don't. Hyperlinks, simple fields and direction
// wrappers (EG_PContentMath, §22.1.2.77) keep their runs (#1273).
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "w:hyperlink" | "w:fldSimple" | "w:dir" | "w:bdo" | "m:oMath" if depth <= MAX_DEPTH => {
for c in k.els() {
node(c, d, out);
}
}
// Markup compatibility: the math choice (we read math), else the fallback.
"mc:AlternateContent" if depth <= MAX_DEPTH => {
if let Some(c) = k.child("mc:Choice").or_else(|| k.child("mc:Fallback")) {
for c in c.els() {
node(c, d, out);
}
}
}
"w:sdt" => {
if let Some(c) = k.child("w:sdtContent") {
for c in c.els() {
node(c, d, out);
}
}
}
_ => {}
}
/// An `m:oMath` element as an equation (structure plus its source XML).
pub fn read_omath(e: &El, jc: MathJc) -> Math {
craft_math::omml::read_omath(&to_craft(e), jc)
}
/// `m:t` (and `w:t`) text of a run, with symbol characters (`w:sym`) and special hyphens.
/// Text in a symbol font (Symbol, Wingdings…) becomes its Unicode equivalent: the math font has
/// no glyphs at those private-use codes (#1273).
fn run_text(r: &El) -> String {
let font = r.child("w:rPr").and_then(|p| p.child("w:rFonts")).and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).unwrap_or("");
let mut s = String::new();
for c in r.els() {
match c.name.as_str() {
"m:t" | "w:t" => s.push_str(&symbol_fonts::map_text(font, &c.text())),
"w:tab" => s.push('\t'),
"w:noBreakHyphen" => s.push('\u{2011}'),
"w:sym" => {
let ch = c.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let f = c.attr("w:font").unwrap_or(font);
s.push(symbol_fonts::symbol_char(f, ch).unwrap_or(ch));
}
}
_ => {}
}
}
s
/// The colour every run of `math` shares, taken off the runs (the run around the equation
/// carries it).
pub fn lift_color(math: &mut Math) -> Option<Rgb> {
craft_math::omml::lift_color(math)
}
fn run(r: &El) -> Option<MRun> {
let text = run_text(r);
if text.is_empty() {
return None;
}
let mut out = MRun { text, ..Default::default() };
if let Some(pr) = r.child("m:rPr") {
out.sty = match pr.child("m:sty").and_then(|s| s.attr("m:val")) {
Some("p") => Some(MSty::Plain),
Some("b") => Some(MSty::Bold),
Some("i") => Some(MSty::Italic),
Some("bi") => Some(MSty::BoldItalic),
_ => None,
};
out.scr = match pr.child("m:scr").and_then(|s| s.attr("m:val")) {
Some("script") => MScr::Script,
Some("fraktur") => MScr::Fraktur,
Some("double-struck") => MScr::DoubleStruck,
Some("sans-serif") => MScr::SansSerif,
Some("monospace") => MScr::Monospace,
_ => MScr::Roman,
};
out.nor = pr.child("m:nor").is_some_and(on);
out.lit = pr.child("m:lit").is_some_and(on);
if let Some(b) = pr.child("m:brk") {
let aln = b.attr("m:alnAt").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
out.brk = Some(aln.min(255) as u8);
}
}
wrpr(r.child("w:rPr"), &mut out);
Some(out)
/// The justification an `m:oMathPara` gives its equations.
pub fn para_jc(para: &El) -> MathJc {
craft_math::omml::para_jc(&to_craft(para))
}
/// The run formatting a math run takes from `w:rPr`: size, colour, normal-text font and weight.
fn wrpr(pr: Option<&El>, out: &mut MRun) {
let Some(pr) = pr else { return };
if let Some(sz) = pr.child("w:sz").and_then(|s| s.attr("w:val")).and_then(|v| v.trim().parse::<f32>().ok()) {
let pt = sz / 2.0;
if pt.is_finite() && (1.0..=1638.0).contains(&pt) {
out.size = Some(pt);
}
}
if let Some(c) = pr.child("w:color").and_then(|c| c.attr("w:val")).and_then(Rgb::parse) {
out.color = Some(c);
}
if out.nor {
// Symbol-font text was mapped to Unicode: it takes the text font.
if let Some(f) =
pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).filter(|f| !symbol_fonts::is_symbol_font(f))
{
out.font = Some(f.to_string());
}
let bold = pr.child("w:b").is_some_and(crate::units::on_off);
let italic = pr.child("w:i").is_some_and(crate::units::on_off);
if bold || italic {
out.sty = Some(match (bold, italic) {
(true, true) => MSty::BoldItalic,
(true, false) => MSty::Bold,
_ => MSty::Italic,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn omath(inner: &str) -> El {
let x = format!(
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{inner}</m:oMath>"#
);
crate::xml::parse(x.as_bytes()).unwrap()
}
#[test]
fn reads_fraction_radical_and_scripts() {
let e = omath(
r#"<m:r><m:t>x=</m:t></m:r><m:f><m:num><m:r><m:t>-b±</m:t></m:r><m:rad><m:radPr><m:degHide m:val="1"/></m:radPr><m:deg/><m:e><m:sSup><m:e><m:r><m:t>b</m:t></m:r></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup><m:r><m:t>-4ac</m:t></m:r></m:e></m:rad></m:num><m:den><m:r><m:t>2a</m:t></m:r></m:den></m:f>"#,
);
let m = read_omath(&e, MathJc::CenterGroup);
assert_eq!(m.nodes.len(), 2);
let MNode::Frac { num, den, kind } = &m.nodes[1] else { panic!("{:#?}", m.nodes) };
assert_eq!(*kind, FracKind::Bar);
assert_eq!(den, &vec![MNode::Run(MRun::new("2a"))]);
let MNode::Rad { deg_hide, e, .. } = &num[1] else { panic!() };
assert!(*deg_hide);
assert!(matches!(e[0], MNode::Script { kind: ScriptKind::Sup, .. }));
assert_eq!(craft_wordmodel::math::to_linear(&m.nodes), "x=(-b±√(b^2-4ac))/2a");
assert!(m.omml.starts_with("<m:oMath>"));
}
#[test]
fn reads_nary_delims_matrix_and_defaults() {
let e = omath(
r#"<m:nary><m:naryPr><m:chr m:val="∑"/><m:limLoc m:val="undOvr"/></m:naryPr><m:sub><m:r><m:t>i</m:t></m:r></m:sub><m:sup/><m:e><m:r><m:t>i</m:t></m:r></m:e></m:nary><m:d><m:dPr><m:begChr m:val="["/><m:endChr m:val=""/></m:dPr><m:e><m:r><m:t>a</m:t></m:r></m:e><m:e><m:r><m:t>b</m:t></m:r></m:e></m:d><m:nary><m:e/></m:nary><m:m><m:mr><m:e/><m:e/></m:mr></m:m>"#,
);
let m = read_omath(&e, MathJc::CenterGroup);
let MNode::Nary { chr, lim_loc, .. } = &m.nodes[0] else { panic!() };
assert_eq!((*chr, *lim_loc), ('∑', Some(LimLoc::UndOvr)));
let MNode::Delim { beg, end, sep, elems, .. } = &m.nodes[1] else { panic!() };
assert_eq!((*beg, *end, *sep, elems.len()), (Some('['), None, Some('|'), 2));
assert!(matches!(m.nodes[2], MNode::Nary { chr: '∫', .. }));
assert!(matches!(&m.nodes[3], MNode::Matrix { rows, .. } if rows.len() == 1 && rows[0].len() == 2));
}
/// Every run's text in the tree, in reading order.
fn texts(m: &mut Math) -> Vec<String> {
let mut out = Vec::new();
craft_wordmodel::math_tree::each_run_mut(&mut m.nodes, &mut |r| out.push(r.text.clone()));
out
}
fn t(s: &str) -> String {
format!("<m:r><m:t>{s}</m:t></m:r>")
}
/// #1273: one equation using every Office Math element of ECMA-376 §22.1 (each argument
/// holding a distinct marker) keeps every argument's text: nothing is silently dropped.
#[test]
fn every_omml_element_keeps_its_content() {
let parts = [
format!("<m:acc><m:accPr><m:chr m:val=\"̃\"/></m:accPr><m:e>{}</m:e></m:acc>", t("A1")),
format!("<m:bar><m:barPr><m:pos m:val=\"top\"/></m:barPr><m:e>{}</m:e></m:bar>", t("B1")),
format!("<m:box><m:boxPr><m:opEmu m:val=\"1\"/></m:boxPr><m:e>{}</m:e></m:box>", t("C1")),
format!("<m:borderBox><m:borderBoxPr><m:strikeH/></m:borderBoxPr><m:e>{}</m:e></m:borderBox>", t("D1")),
format!("<m:d><m:dPr><m:begChr m:val=\"[\"/></m:dPr><m:e>{}</m:e><m:e>{}</m:e></m:d>", t("E1"), t("E2")),
format!("<m:eqArr><m:e>{}</m:e><m:e>{}</m:e></m:eqArr>", t("F1"), t("F2")),
format!("<m:f><m:fPr><m:type m:val=\"skw\"/></m:fPr><m:num>{}</m:num><m:den>{}</m:den></m:f>", t("G1"), t("G2")),
format!("<m:func><m:fName>{}</m:fName><m:e>{}</m:e></m:func>", t("H1"), t("H2")),
format!("<m:groupChr><m:groupChrPr><m:chr m:val=\"⏞\"/><m:pos m:val=\"top\"/></m:groupChrPr><m:e>{}</m:e></m:groupChr>", t("I1")),
format!("<m:limLow><m:e>{}</m:e><m:lim>{}</m:lim></m:limLow>", t("J1"), t("J2")),
format!("<m:limUpp><m:e>{}</m:e><m:lim>{}</m:lim></m:limUpp>", t("K1"), t("K2")),
format!("<m:m><m:mr><m:e>{}</m:e><m:e>{}</m:e></m:mr><m:mr><m:e>{}</m:e><m:e/></m:mr></m:m>", t("L1"), t("L2"), t("L3")),
format!(
"<m:nary><m:naryPr><m:chr m:val=\"∑\"/></m:naryPr><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:nary>",
t("M1"),
t("M2"),
t("M3")
),
format!("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr><m:e>{}</m:e></m:phant>", t("N1")),
format!("<m:rad><m:deg>{}</m:deg><m:e>{}</m:e></m:rad>", t("O1"), t("O2")),
format!("<m:sPre><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:sPre>", t("P1"), t("P2"), t("P3")),
format!("<m:sSub><m:e>{}</m:e><m:sub>{}</m:sub></m:sSub>", t("Q1"), t("Q2")),
format!("<m:sSubSup><m:e>{}</m:e><m:sub>{}</m:sub><m:sup>{}</m:sup></m:sSubSup>", t("R1"), t("R2"), t("R3")),
format!("<m:sSup><m:e>{}</m:e><m:sup>{}</m:sup></m:sSup>", t("S1"), t("S2")),
// Run-level wrappers allowed in math (EG_PContentMath): hyperlinks, simple fields,
// tracked insertions, content controls, markup compatibility.
format!("<w:hyperlink w:anchor=\"x\">{}</w:hyperlink>", t("T1")),
format!("<w:fldSimple w:instr=\"QUOTE\">{}</w:fldSimple>", t("T2")),
format!("<w:ins w:id=\"1\" w:author=\"a\">{}</w:ins>", t("T3")),
format!("<w:sdt><w:sdtContent>{}</w:sdtContent></w:sdt>", t("T4")),
format!(
"<mc:AlternateContent><mc:Choice Requires=\"m\">{}</mc:Choice><mc:Fallback><w:r><w:t>picture</w:t></w:r></mc:Fallback></mc:AlternateContent>",
t("T5")
),
"<w:r><w:t>T6</w:t></w:r>".to_string(),
];
let x = format!(
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">{}</m:oMath>"#,
parts.concat()
);
let e = crate::xml::parse(x.as_bytes()).unwrap();
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
let markers = [
"A1", "B1", "C1", "D1", "E1", "E2", "F1", "F2", "G1", "G2", "H1", "H2", "I1", "J1", "J2", "K1", "K2", "L1", "L2", "L3", "M1", "M2", "M3",
"N1", "O1", "O2", "P1", "P2", "P3", "Q1", "Q2", "R1", "R2", "R3", "S1", "S2", "T1", "T2", "T3", "T4", "T5", "T6",
];
for k in markers {
assert!(got.contains(k), "{k} dropped from {got}");
}
assert!(!got.contains("picture"), "the math choice wins over the fallback: {got}");
}
/// #1273: deleted text stays out; symbol characters and symbol-font text become Unicode.
#[test]
fn symbols_and_deletions_in_math() {
let e = omath(
r#"<m:r><m:t>x</m:t></m:r><w:del w:id="1" w:author="a"><m:r><m:t>gone</m:t></m:r></w:del><m:r><w:sym w:font="Symbol" w:char="F061"/></m:r><m:r><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr><m:t>&#xF062;</m:t><w:noBreakHyphen/></m:r><w:r><w:rPr><w:rFonts w:ascii="Symbol"/></w:rPr><w:t>&#xF067;</w:t></w:r>"#,
);
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
assert_eq!(got, "xαβ\u{2011}γ");
let mut font = None;
craft_wordmodel::math_tree::each_run_mut(&mut m.nodes, &mut |r| {
if r.nor {
font = Some(r.font.clone());
}
});
assert_eq!(font, Some(None), "mapped symbol text takes the text font");
}
/// Hostile nesting through the wrappers is bounded (no stack overflow, no panic).
#[test]
fn deeply_nested_wrappers_are_bounded() {
// Deeper than the math limit, within the XML parser's own depth limit.
let n = 120;
let inner = format!("{}{}{}", "<w:hyperlink>".repeat(n), t("deep"), "</w:hyperlink>".repeat(n));
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
let inner = format!(
"{}{}{}",
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:Choice Requires=\"m\">".repeat(n),
t("deep"),
"</mc:Choice></mc:AlternateContent>".repeat(n)
);
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
}
/// `m:mathPr` in the settings part.
pub fn read_math_pr(e: &El) -> MathProps {
craft_math::omml::read_math_pr(&to_craft(e))
}
+10 -471
View File
@@ -1,486 +1,25 @@
//! [`craft_wordmodel::math`] tree → Office Math (OMML).
//! Office Math (OMML) written with `craft_math::omml`.
use craft_wordmodel::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
ScriptKind, parse_linear,
};
use craft_wordmodel::math::{Math, MathProps};
use craft_wordmodel::props::Rgb;
use crate::xml::W;
/// Write an equation: `m:oMath`, inside `m:oMathPara` for a display equation. The source OMML
/// is written back unchanged when the equation came from a file. `color` (the equation's run
/// colour) goes on every math run without its own: OMML has no equation-level colour (#1467).
/// colour) goes on every math run without its own: OMML has no equation-level colour.
pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math, color: Option<Rgb>) {
let tinted;
let math = if color == omml_color(&math.omml) {
math
} else {
// The kept source XML has the old colour baked in: write from the nodes instead.
let mut m = math.clone();
if m.nodes.is_empty() {
m.nodes = parse_linear(linear);
}
if let Some(c) = color {
craft_wordmodel::math_tree::each_run_mut(&mut m.nodes, &mut |r| {
r.color.get_or_insert(c);
});
}
m.omml.clear();
tinted = m;
&tinted
};
if display {
w.open("m:oMathPara", &[]);
let jc = match math.jc {
MathJc::CenterGroup => None,
MathJc::Center => Some("center"),
MathJc::Left => Some("left"),
MathJc::Right => Some("right"),
};
if let Some(jc) = jc {
w.open("m:oMathParaPr", &[]);
w.empty("m:jc", &[("m:val", jc)]);
w.close("m:oMathParaPr");
}
}
if !math.omml.is_empty() && math.omml.starts_with("<m:oMath") && crate::xml::parse(wrap_ns(&math.omml).as_bytes()).is_ok() {
w.raw(&math.omml);
} else {
let parsed;
let nodes: &Arg = if math.nodes.is_empty() {
parsed = parse_linear(linear);
&parsed
} else {
&math.nodes
};
w.open("m:oMath", &[]);
arg_inner(w, nodes, 0);
w.close("m:oMath");
}
if display {
w.close("m:oMathPara");
}
w.raw(&craft_math::omml::write_str(linear, display, math, color));
}
/// Count the equation's numbered rows; when some use an automatic number (`#` alone), a copy
/// with the numbers written out as `(n)`.
/// `math` with its `#(n)` equation numbers resolved from `counter`, when it has any.
pub fn resolve_numbers(math: &Math, display: bool, counter: &mut u32) -> Option<Math> {
if !display {
return None;
}
let [MNode::EqArr { rows }] = math.nodes.as_slice() else { return None };
let mut out = rows.clone();
let mut changed = false;
for row in out.iter_mut() {
if !craft_wordmodel::math::has_number_mark(row) {
continue;
}
*counter = counter.saturating_add(1);
// Nothing after the `#`: an automatic number.
let mut after = false;
let mut empty = true;
for n in row.iter() {
match n {
MNode::Run(r) if !after => {
if let Some(k) = r.text.find('#') {
after = true;
if !r.text.get(k + 1..).unwrap_or("").is_empty() {
empty = false;
}
}
}
_ if after => empty = false,
_ => {}
}
}
if empty {
let n = vec![MNode::Run(MRun::new(counter.to_string()))];
row.push(MNode::Delim { beg: Some('('), end: Some(')'), sep: None, grow: true, shp_match: false, elems: vec![n] });
changed = true;
}
}
changed.then(|| Math { nodes: vec![MNode::EqArr { rows: out }], omml: String::new(), ..math.clone() })
craft_math::omml::resolve_numbers(math, display, counter)
}
/// The equation colour the kept source OMML carries, as the reader lifts it.
fn omml_color(omml: &str) -> Option<Rgb> {
if omml.is_empty() {
return None;
}
let root = crate::xml::parse(wrap_ns(omml).as_bytes()).ok()?;
let e = root.child("m:oMath")?;
crate::read::math::lift_color(&mut crate::read::math::read_omath(e, MathJc::default()))
}
/// The kept XML with namespace declarations, for validating it on its own.
fn wrap_ns(xml: &str) -> String {
let decl: String = crate::xml::body_ns().iter().filter(|(k, _)| k.starts_with("xmlns:")).map(|(k, v)| format!(" {k}=\"{v}\"")).collect();
format!("<w:x{decl}>{xml}</w:x>")
}
/// The settings part's `m:mathPr` (CT_MathPr order; `m:wrapIndent` and `m:wrapRight` are a choice).
/// `m:mathPr`.
pub fn math_pr(w: &mut W, m: &MathProps) {
w.open("m:mathPr", &[]);
let bin = match m.brk_bin {
BrkBin::Before => "before",
BrkBin::After => "after",
BrkBin::Repeat => "repeat",
};
val(w, "m:brkBin", bin);
let sub = match m.brk_bin_sub {
BrkBinSub::MinusMinus => "--",
BrkBinSub::MinusPlus => "-+",
BrkBinSub::PlusMinus => "+-",
};
val(w, "m:brkBinSub", sub);
if m.wrap_right {
w.empty("m:wrapRight", &[]);
} else {
let indent = if m.wrap_indent.is_finite() { m.wrap_indent.clamp(0.0, 1584.0) } else { craft_wordmodel::math::DEFAULT_WRAP_INDENT };
val(w, "m:wrapIndent", &crate::units::twips(indent));
}
w.close("m:mathPr");
}
fn val(w: &mut W, name: &str, v: &str) {
w.empty(name, &[("m:val", v)]);
}
fn arg(w: &mut W, name: &str, a: &[MNode], depth: usize) {
if a.is_empty() {
w.empty(name, &[]);
return;
}
w.open(name, &[]);
arg_inner(w, a, depth + 1);
w.close(name);
}
fn arg_inner(w: &mut W, a: &[MNode], depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a {
node(w, n, depth);
}
}
fn ctrl(w: &mut W) {
w.open("m:ctrlPr", &[]);
w.open("w:rPr", &[]);
w.empty("w:rFonts", &[("w:ascii", "Cambria Math"), ("w:hAnsi", "Cambria Math")]);
w.empty("w:i", &[]);
w.close("w:rPr");
w.close("m:ctrlPr");
}
fn char_prop(w: &mut W, name: &str, c: Option<char>) {
let s = c.map(|c| c.to_string()).unwrap_or_default();
val(w, name, &s);
}
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
let MNode::Matrix { rows, col_jc } = n else { return };
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
// caps rather than written as rows × widest cells (#3643).
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
w.open("m:m", &[]);
w.open("m:mPr", &[]);
w.open("m:mcs", &[]);
for c in 0..cols {
let jc = match col_jc.get(c).copied().unwrap_or_default() {
ColJc::Center => "center",
ColJc::Left => "left",
ColJc::Right => "right",
};
w.open("m:mc", &[]);
w.open("m:mcPr", &[]);
val(w, "m:count", "1");
val(w, "m:mcJc", jc);
w.close("m:mcPr");
w.close("m:mc");
}
w.close("m:mcs");
ctrl(w);
w.close("m:mPr");
for row in rows {
w.open("m:mr", &[]);
for c in 0..cols {
arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth);
}
w.close("m:mr");
}
w.close("m:m");
}
fn write_nary(w: &mut W, n: &MNode, depth: usize) {
let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return };
w.open("m:nary", &[]);
w.open("m:naryPr", &[]);
if *chr != '∫' {
char_prop(w, "m:chr", Some(*chr));
}
match lim_loc {
Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"),
Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"),
None => {}
}
if *grow {
val(w, "m:grow", "1");
}
if *sub_hide {
val(w, "m:subHide", "1");
}
if *sup_hide {
val(w, "m:supHide", "1");
}
ctrl(w);
w.close("m:naryPr");
arg(w, "m:sub", sub, depth);
arg(w, "m:sup", sup, depth);
arg(w, "m:e", e, depth);
w.close("m:nary");
}
fn node(w: &mut W, n: &MNode, depth: usize) {
match n {
MNode::Run(r) => run(w, r),
MNode::Frac { kind, num, den } => {
w.open("m:f", &[]);
w.open("m:fPr", &[]);
match kind {
FracKind::Bar => {}
FracKind::Skewed => val(w, "m:type", "skw"),
FracKind::Linear => val(w, "m:type", "lin"),
FracKind::NoBar => val(w, "m:type", "noBar"),
}
ctrl(w);
w.close("m:fPr");
arg(w, "m:num", num, depth);
arg(w, "m:den", den, depth);
w.close("m:f");
}
MNode::Script { kind, base, sub, sup } => {
let (el, pr) = match kind {
ScriptKind::Sup => ("m:sSup", "m:sSupPr"),
ScriptKind::Sub => ("m:sSub", "m:sSubPr"),
ScriptKind::SubSup => ("m:sSubSup", "m:sSubSupPr"),
ScriptKind::Pre => ("m:sPre", "m:sPrePr"),
};
w.open(el, &[]);
w.open(pr, &[]);
ctrl(w);
w.close(pr);
if *kind == ScriptKind::Pre {
arg(w, "m:sub", sub, depth);
arg(w, "m:sup", sup, depth);
arg(w, "m:e", base, depth);
} else {
arg(w, "m:e", base, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
arg(w, "m:sub", sub, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
arg(w, "m:sup", sup, depth);
}
}
w.close(el);
}
MNode::Rad { deg, deg_hide, e } => {
w.open("m:rad", &[]);
w.open("m:radPr", &[]);
if *deg_hide {
val(w, "m:degHide", "1");
}
ctrl(w);
w.close("m:radPr");
arg(w, "m:deg", deg, depth);
arg(w, "m:e", e, depth);
w.close("m:rad");
}
MNode::Nary { .. } => write_nary(w, n, depth),
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
w.open("m:d", &[]);
w.open("m:dPr", &[]);
if *beg != Some('(') {
char_prop(w, "m:begChr", *beg);
}
if *sep != Some('|') {
char_prop(w, "m:sepChr", *sep);
}
if *end != Some(')') {
char_prop(w, "m:endChr", *end);
}
if !grow {
val(w, "m:grow", "0");
}
if *shp_match {
val(w, "m:shp", "match");
}
ctrl(w);
w.close("m:dPr");
for e in elems {
arg(w, "m:e", e, depth);
}
w.close("m:d");
}
MNode::Func { name, e } => {
w.open("m:func", &[]);
w.open("m:funcPr", &[]);
ctrl(w);
w.close("m:funcPr");
arg(w, "m:fName", name, depth);
arg(w, "m:e", e, depth);
w.close("m:func");
}
MNode::Lim { upper, e, lim } => {
let (el, pr) = if *upper { ("m:limUpp", "m:limUppPr") } else { ("m:limLow", "m:limLowPr") };
w.open(el, &[]);
w.open(pr, &[]);
ctrl(w);
w.close(pr);
arg(w, "m:e", e, depth);
arg(w, "m:lim", lim, depth);
w.close(el);
}
MNode::Acc { chr, e } => {
w.open("m:acc", &[]);
w.open("m:accPr", &[]);
char_prop(w, "m:chr", Some(*chr));
ctrl(w);
w.close("m:accPr");
arg(w, "m:e", e, depth);
w.close("m:acc");
}
MNode::Bar { top, e } => {
w.open("m:bar", &[]);
w.open("m:barPr", &[]);
val(w, "m:pos", if *top { "top" } else { "bot" });
ctrl(w);
w.close("m:barPr");
arg(w, "m:e", e, depth);
w.close("m:bar");
}
MNode::BorderBox { hide, strike, e } => {
w.open("m:borderBox", &[]);
w.open("m:borderBoxPr", &[]);
for (on, name) in hide.iter().zip(["m:hideTop", "m:hideBot", "m:hideLeft", "m:hideRight"]) {
if *on {
val(w, name, "1");
}
}
for (on, name) in strike.iter().zip(["m:strikeH", "m:strikeV", "m:strikeBLTR", "m:strikeTLBR"]) {
if *on {
val(w, name, "1");
}
}
ctrl(w);
w.close("m:borderBoxPr");
arg(w, "m:e", e, depth);
w.close("m:borderBox");
}
MNode::Boxed { e } => {
w.open("m:box", &[]);
w.open("m:boxPr", &[]);
ctrl(w);
w.close("m:boxPr");
arg(w, "m:e", e, depth);
w.close("m:box");
}
MNode::GroupChr { chr, top, e } => {
w.open("m:groupChr", &[]);
w.open("m:groupChrPr", &[]);
char_prop(w, "m:chr", Some(*chr));
val(w, "m:pos", if *top { "top" } else { "bot" });
val(w, "m:vertJc", if *top { "bot" } else { "top" });
ctrl(w);
w.close("m:groupChrPr");
arg(w, "m:e", e, depth);
w.close("m:groupChr");
}
MNode::EqArr { rows } => {
w.open("m:eqArr", &[]);
w.open("m:eqArrPr", &[]);
// Numbered rows (`#`) stretch across the line so the number reaches the margin.
if rows.iter().any(|r| craft_wordmodel::math::has_number_mark(r)) {
val(w, "m:maxDist", "1");
}
ctrl(w);
w.close("m:eqArrPr");
for r in rows {
arg(w, "m:e", r, depth);
}
w.close("m:eqArr");
}
MNode::Matrix { .. } => write_matrix(w, n, depth),
MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => {
w.open("m:phant", &[]);
w.open("m:phantPr", &[]);
val(w, "m:show", if *show { "1" } else { "0" });
for (on, name) in [(*zero_wid, "m:zeroWid"), (*zero_asc, "m:zeroAsc"), (*zero_desc, "m:zeroDesc")] {
if on {
val(w, name, "1");
}
}
ctrl(w);
w.close("m:phantPr");
arg(w, "m:e", e, depth);
w.close("m:phant");
}
}
}
fn run(w: &mut W, r: &MRun) {
w.open("m:r", &[]);
let sty = r.sty.map(|s| match s {
MSty::Plain => "p",
MSty::Bold => "b",
MSty::Italic => "i",
MSty::BoldItalic => "bi",
});
let scr = match r.scr {
MScr::Roman => None,
MScr::Script => Some("script"),
MScr::Fraktur => Some("fraktur"),
MScr::DoubleStruck => Some("double-struck"),
MScr::SansSerif => Some("sans-serif"),
MScr::Monospace => Some("monospace"),
};
if sty.is_some() || scr.is_some() || r.nor || r.lit || r.brk.is_some() {
w.open("m:rPr", &[]);
if r.lit {
w.empty("m:lit", &[]);
}
if r.nor {
w.empty("m:nor", &[]);
}
if let Some(s) = scr {
val(w, "m:scr", s);
}
if let Some(s) = sty {
val(w, "m:sty", s);
}
match r.brk {
Some(0) => w.empty("m:brk", &[]),
Some(n) => w.empty("m:brk", &[("m:alnAt", &n.to_string())]),
None => {}
}
w.close("m:rPr");
}
w.open("w:rPr", &[]);
let font = r.font.as_deref().filter(|_| r.nor).unwrap_or("Cambria Math");
w.empty("w:rFonts", &[("w:ascii", font), ("w:hAnsi", font)]);
if let Some(c) = r.color {
w.val("w:color", &c.hex());
}
if let Some(sz) = r.size {
let hp = (sz * 2.0).round().clamp(2.0, 3276.0) as i64;
w.val("w:sz", &hp.to_string());
w.val("w:szCs", &hp.to_string());
}
w.close("w:rPr");
w.leaf("m:t", &[("xml:space", "preserve")], &r.text);
w.close("m:r");
let mut o = craft_math::omml::W::default();
craft_math::omml::math_pr(&mut o, m);
w.raw(&o.s);
}
+25 -1
View File
@@ -982,7 +982,12 @@ fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], o
braced(base, output, depth);
return;
}
braced(base, output, depth);
// An operator name takes its scripts directly (`\sin^{2}`): in braces it would no longer apply
// to what follows it when read back.
match base {
[MNode::Run(r)] if is_function_name(r) => run_node(r, output),
_ => braced(base, output, depth),
}
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
output.push('_');
braced(sub, output, depth);
@@ -993,6 +998,11 @@ fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], o
}
}
/// Is `r` an operator name the writer writes as a command (`\sin`, `\log`)?
fn is_function_name(r: &MRun) -> bool {
!r.nor && r.sty == Some(MSty::Plain) && r.scr == MScr::Roman && FUNCTION_NAMES.contains(&r.text.as_str())
}
fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
output.push_str("\\sqrt");
if !degree_hidden && !degree.is_empty() {
@@ -1289,4 +1299,18 @@ mod tests {
// The writer keeps its own names.
assert_eq!(to_latex(&parse_latex(r"\infinity")), to_latex(&parse_latex(r"\infty")));
}
/// An operator name with a script still applies to its argument when the LaTeX is read back.
#[test]
fn operator_names_take_their_scripts_directly() {
for src in [r"\sin^2\theta+\cos^2\theta=1", r"\log_2 x", r"\ln^{3}(x+1)"] {
let tree = parse_latex(src);
let tex = to_latex(&tree);
assert_eq!(crate::math::to_linear(&parse_latex(&tex)), crate::math::to_linear(&tree), "{src}: {tex}");
assert!(!tex.contains(r"{\sin }") && !tex.contains(r"{\log }"), "{tex}");
}
assert!(to_latex(&parse_latex(r"\sin^2\theta")).starts_with(r"\sin ^{2}"));
// Other bases keep their braces.
assert_eq!(to_latex(&parse_latex("x^2")), "{x}^{2}");
}
}
+2 -8
View File
@@ -18,10 +18,6 @@ fn equations() -> Vec<(String, math::Arg, bool)> {
v
}
/// Equations whose LaTeX this crate writes in a form it reads back differently (a function
/// raised to a power: `\sin^2\theta` comes back as `(\sin)^2 θ`). Known; kept visible here.
const LATEX_ROUND_TRIP_GAPS: &[&str] = &["func"];
#[test]
fn every_corpus_equation_reads_without_unknown_commands() {
for (id, tree, _) in equations() {
@@ -54,10 +50,8 @@ fn every_corpus_equation_survives_mathml_and_latex() {
let lin = math::to_linear(&tree);
let ml = mathml::to_mathml(&tree, display, "");
assert_eq!(mathml::parse(&ml).and_then(|m| mathml::read(&m)), Some((lin.clone(), display)), "{id}: {ml}");
if !LATEX_ROUND_TRIP_GAPS.contains(&id.as_str()) {
let tex = math_latex::to_latex(&tree);
assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}");
}
let tex = math_latex::to_latex(&tree);
assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}");
}
}
+8 -2
View File
@@ -18,8 +18,10 @@ are one crate.
| `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date and time created (2:55, 2:60). |
| `parse_xmp(&str) -> Result<XmpData>` | `Metadata`, every property (`prefix:name`, structs flattened as `a:b/c:d`) and the structured values. `parse_xmp_with` also canonicalises the caller's own namespaces. |
| `write_xmp(&Metadata) -> String` | A complete `<?xpacket?>` packet. `write_xmp_with` sets the toolkit name and adds simple properties in the caller's namespace. |
| `XmpData::has_location_metadata`, `XmpData::properties_truncated` | Whether a packet names GPS or a place anywhere (array items and repeated structs included), and whether the flattened map is complete, for a caller that must prove a file carries no location. `parse_gps_position` reads an XMP coordinate pair. |
| `embedded_xmp(&Embedded)`, `parse_xmp_pair` | A JPEG's standard XMP with the Extended XMP it links (`xmpNote:HasExtendedXMP`) read as one packet; an `EmbeddedXmpError` (keeping the standard packet) when the link or the extension cannot be read whole. `extract` and `from_embedded` use it best-effort. Extended XMP: 16 MiB, 4096 chunks, only the linked GUID's chunks counted. |
| `merge_xmp(existing, fresh, MergeRules)` | Updates a packet another program wrote: owned properties are replaced, everything else stays byte for byte. |
| `parse_gpx(&str) -> Result<Tracklog>` | GPX track points; `Tracklog::locate` gives the position at a capture time. |
| `parse_gpx(&str) -> Result<Tracklog>` | GPX track points with their track and segment; `Tracklog::locate` gives the position at a capture time, `Tracklog::only_track` one track. |
| `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF, Canon CR3 and JPEG XL containers and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. |
| `cr3::parse_cr3` | The Canon CR3 container: `CMT1`…`CMT4`, the XMP `uuid` box, the thumbnail, the tracks. |
| `jxl::metadata` | The `Exif` and `xml ` boxes of a JPEG XL container, plain or Brotli-wrapped in `brob`. |
@@ -30,6 +32,10 @@ are one crate.
HEIF, PSD and MRW files are read by `craft-heif`, `craft-psd` and `craft-raw`. Put the blocks they
return into an `Embedded` and call `from_embedded`.
## Validation
Only well-formed values become typed metadata; the raw property stays in `XmpData::properties`. XMP orientation must be an integral code 1 to 8; ISO and pixel dimensions exact whole numbers in range (compared as decimal digits); GPS minutes and seconds under 60, the sign from the axis letter. IPTC Time Created counts only as a whole `HHMMSS` or `HHMMSS±HHMM`. A carriage return in written text is `&#13;`.
## Capture time
EXIF candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock
@@ -50,7 +56,7 @@ A foreign namespace that uses a recognised prefix cannot supply recognised prope
## Limits
XMP: 64 levels, 200 000 nodes, 1 MiB of flattened keys and 8 MiB of opaque names per packet
XMP: 64 levels, 200 000 nodes, 512 namespace declarations in scope, 1 MiB of flattened keys and 8 MiB of opaque names per packet
(`XmpError::TooComplex`). GPX: 64 levels, 5 000 000 points. MPF: 16 index entries. CR3 and JPEG XL: 4096 boxes; a `brob` box inflates to at most 32 MiB. JPEG ICC is returned only when its APP2 chunks form a
complete, consistent, uniquely numbered sequence. TIFF limits are `craft-tiff`'s. No input panics:
proptests feed random bytes to every container walker and random text to the XMP parser, and a sample
+428 -16
View File
@@ -25,9 +25,18 @@ pub struct Embedded {
pub xmp: Option<String>,
/// Extended XMP (JPEG only), reassembled.
pub xmp_extended: Option<String>,
/// The GUID every Extended XMP segment carries, set only when all segments share it and
/// their chunks tile exactly the full length they declare (no gap, overlap or excess).
pub xmp_extended_guid: Option<String>,
pub icc: Option<Vec<u8>>,
/// IPTC-IIM stream.
pub iptc: Option<Vec<u8>>,
/// True when the PNG/WebP chunk stream could not be fully inspected.
pub chunk_walk_incomplete: bool,
/// Why the Extended XMP the standard packet links to (`xmpNote:HasExtendedXMP`) could not be
/// read: its chunks are missing, truncated, too large, or do not cover their declared length
/// exactly once. [`Embedded::xmp_extended`] is `None` then.
pub xmp_extended_error: Option<String>,
}
/// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result.
@@ -58,11 +67,14 @@ pub fn embedded(bytes: &[u8]) -> Embedded {
exif: Some(bytes.to_vec()),
xmp: ifd0.bytes(tags::XMP).map(|b| String::from_utf8_lossy(b).into_owned()),
xmp_extended: None,
xmp_extended_guid: None,
icc: ifd0.bytes(tags::ICC_PROFILE).map(|b| b.to_vec()),
iptc: ifd0.value(tags::IPTC_NAA).map(|v| match v {
craft_tiff::Value::Byte(b) | craft_tiff::Value::Undefined(b) => b.clone(),
other => craft_tiff::writer::encode(t.order, other),
}),
chunk_walk_incomplete: false,
xmp_extended_error: None,
}
} else {
Embedded::default()
@@ -74,14 +86,27 @@ const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
/// Walk JPEG markers up to SOS and collect APP1 Exif / XMP, APP2 ICC and APP13 IPTC.
pub fn jpeg_segments(b: &[u8]) -> Embedded {
jpeg_segments_inner(b, false).0
}
/// Walk JPEG metadata and return every ordinary XMP APP1 packet in segment order, along with the
/// usual [`Embedded`] projection (which retains only the first ordinary packet).
pub fn jpeg_segments_with_all_xmp(b: &[u8]) -> (Embedded, Vec<String>) {
jpeg_segments_inner(b, true)
}
fn jpeg_segments_inner(b: &[u8], collect_all_xmp: bool) -> (Embedded, Vec<String>) {
let mut out = Embedded::default();
let mut xmp_packets = Vec::new();
if !b.starts_with(&[0xff, 0xd8]) {
return out;
return (out, xmp_packets);
}
let mut icc_parts: [Option<&[u8]>; 255] = [None; 255];
let mut icc_total = None;
let mut icc_invalid = false;
let mut ext: Vec<(u32, Vec<u8>)> = Vec::new();
// GUID and declared full length of every Extended XMP segment, to verify they belong together
let mut ext_headers: Vec<(&[u8], u32)> = Vec::new();
let mut i = 2usize;
while i + 4 <= b.len() {
if b[i] != 0xff {
@@ -111,13 +136,22 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
match marker {
0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()),
0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => {
out.xmp = Some(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned());
let packet = String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned();
if collect_all_xmp {
xmp_packets.push(packet.clone());
}
out.xmp = Some(packet);
}
0xe1 if payload.starts_with(XMP_SIG) && collect_all_xmp => {
xmp_packets.push(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned());
}
0xe1 if payload.starts_with(XMP_EXT_SIG) => {
// GUID (32) + full length (4) + offset (4) + data
let p = &payload[XMP_EXT_SIG.len()..];
if p.len() >= 40 {
let full = u32::from_be_bytes([p[32], p[33], p[34], p[35]]);
let off = u32::from_be_bytes([p[36], p[37], p[38], p[39]]);
ext_headers.push((&p[..32], full));
ext.push((off, p[40..].to_vec()));
}
}
@@ -180,11 +214,114 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded {
out.icc = Some(profile);
}
}
if !ext.is_empty() {
attach_extension(&mut out, b, ext, &ext_headers);
(out, xmp_packets)
}
/// Put the Extended XMP of JPEG `b` into `out`: when the standard packet links one, only that GUID's
/// chunks count, checked strictly ([`extended_xmp`]); otherwise every chunk, offset-sorted.
fn attach_extension(out: &mut Embedded, b: &[u8], mut ext: Vec<(u32, Vec<u8>)>, ext_headers: &[(&[u8], u32)]) {
// a standard packet that links an extension: only that GUID's chunks count, checked strictly
let reference = (!ext.is_empty())
.then(|| out.xmp.as_deref().and_then(|x| crate::parse_xmp(x).ok()).and_then(|d| crate::xmp::extended_xmp_reference(&d).ok().flatten()))
.flatten();
if let Some(guid) = reference {
match extended_xmp(b, &guid) {
Ok(packet) => {
out.xmp_extended = Some(packet);
out.xmp_extended_guid = Some(guid);
}
Err(error) => out.xmp_extended_error = Some(error),
}
} else if !ext.is_empty() {
ext.sort_by_key(|p| p.0);
out.xmp_extended_guid = verified_extension_guid(ext_headers, &ext);
out.xmp_extended = Some(String::from_utf8_lossy(&ext.into_iter().flat_map(|p| p.1).collect::<Vec<u8>>()).into_owned());
}
out
}
/// Most bytes an Extended XMP packet may have, and most chunks it may be split into.
const MAX_EXTENDED_XMP_BYTES: usize = 16 * 1024 * 1024;
const MAX_EXTENDED_XMP_CHUNKS: usize = 4096;
/// The Extended XMP packet of a JPEG for `guid` (upper-case hex, from the standard packet's
/// `xmpNote:HasExtendedXMP`; Adobe XMP Specification Part 3, 1.1.3.1): the APP1 chunks carrying
/// that GUID, which must all declare one full length of at most [`MAX_EXTENDED_XMP_BYTES`] and
/// together cover it exactly once, in at most [`MAX_EXTENDED_XMP_CHUNKS`] complete segments.
/// Chunks of any other GUID are not looked at, so they cannot exhaust the limits.
fn extended_xmp(b: &[u8], guid: &str) -> Result<String, String> {
let mut chunks: Vec<(u32, &[u8])> = Vec::new();
let (mut total, mut received) = (None, 0usize);
let mut i = 2usize;
while let (Some(&0xff), Some(&marker)) = (b.get(i), b.get(i + 1)) {
if marker == 0xff {
i += 1;
continue;
}
if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 {
i += 2;
continue;
}
if marker == 0xd9 || marker == 0xda {
break;
}
let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))).filter(|&l| l >= 2) else { break };
let (start, end) = (i + 4, i + 2 + len);
let payload = b.get(start..end.min(b.len())).unwrap_or_default();
if let Some(p) = payload.strip_prefix(XMP_EXT_SIG).filter(|_| marker == 0xe1)
&& p.get(..32).is_some_and(|g| g.eq_ignore_ascii_case(guid.as_bytes()))
{
if end > b.len() {
return Err("Extended XMP APP1 segment is truncated".into());
}
let (Some(full), Some(offset), Some(data)) = (p.get(32..36), p.get(36..40), p.get(40..)) else {
return Err("Extended XMP APP1 header is truncated".into());
};
let full = u32::from_be_bytes([full[0], full[1], full[2], full[3]]);
if *total.get_or_insert(full) != full {
return Err("Extended XMP chunks declare different full lengths".into());
}
received = received.saturating_add(data.len());
if usize::try_from(full).map_or(true, |f| f > MAX_EXTENDED_XMP_BYTES) || received > MAX_EXTENDED_XMP_BYTES {
return Err("Extended XMP is larger than 16 MiB".into());
}
if chunks.len() >= MAX_EXTENDED_XMP_CHUNKS {
return Err("Extended XMP has too many chunks".into());
}
chunks.push((u32::from_be_bytes([offset[0], offset[1], offset[2], offset[3]]), data));
}
i = end;
}
let total = total.ok_or("the referenced Extended XMP is missing")?;
chunks.sort_by_key(|c| c.0);
let mut packet = Vec::with_capacity(received);
for (offset, data) in chunks {
if usize::try_from(offset).ok() != Some(packet.len()) {
return Err("Extended XMP chunks have a gap, an overlap or a repeated offset".into());
}
packet.extend_from_slice(data);
}
if u32::try_from(packet.len()).ok() != Some(total) || total == 0 {
return Err("Extended XMP chunks do not cover their declared full length".into());
}
String::from_utf8(packet).map_err(|_| "Extended XMP is not UTF-8".into())
}
/// The GUID shared by all Extended XMP segments when their offset-sorted chunks are contiguous from
/// zero and add up to the full length every segment declares; `None` otherwise.
fn verified_extension_guid(headers: &[(&[u8], u32)], chunks_by_offset: &[(u32, Vec<u8>)]) -> Option<String> {
let (&(guid, full), rest) = headers.split_first()?;
if rest.iter().any(|&(other_guid, other_full)| other_guid != guid || other_full != full) {
return None;
}
let mut next = 0u64;
for (offset, data) in chunks_by_offset {
if u64::from(*offset) != next {
return None;
}
next = next.checked_add(u64::try_from(data.len()).ok()?)?;
}
(next == u64::from(full)).then(|| String::from_utf8_lossy(guid).into_owned())
}
/// IPTC-IIM block (resource 0x0404) from a Photoshop image-resource stream (`8BIM` records).
@@ -209,10 +346,41 @@ fn photoshop_iptc(mut b: &[u8]) -> Option<Vec<u8>> {
pub fn png_chunks(b: &[u8]) -> Embedded {
let mut out = Embedded::default();
let mut i = 8usize;
while i + 12 <= b.len() {
let len = u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize;
let typ = &b[i + 4..i + 8];
let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break };
let mut ended = false;
loop {
let Some(header_end) = i.checked_add(8).filter(|end| *end <= b.len()) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(header) = b.get(i..header_end) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(length_bytes) = header.get(..4) else {
out.chunk_walk_incomplete = true;
break;
};
let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else {
out.chunk_walk_incomplete = true;
break;
};
let len = u32::from_be_bytes(length_bytes) as usize;
let Some(data_end) = header_end.checked_add(len) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(chunk_end) = data_end.checked_add(4).filter(|end| *end <= b.len()) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(typ) = header.get(4..8) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(data) = b.get(header_end..data_end) else {
out.chunk_walk_incomplete = true;
break;
};
match typ {
b"eXIf" => out.exif = Some(crate::strip_exif_header(data).to_vec()),
b"iTXt" if data.starts_with(b"XML:com.adobe.xmp\0") => {
@@ -227,10 +395,17 @@ pub fn png_chunks(b: &[u8]) -> Embedded {
}
}
}
b"IEND" => break,
b"IEND" => {
ended = len == 0 && chunk_end == b.len();
out.chunk_walk_incomplete |= !ended;
break;
}
_ => {}
}
i += 12 + len;
i = chunk_end;
}
if !ended {
out.chunk_walk_incomplete = true;
}
out
}
@@ -238,18 +413,69 @@ pub fn png_chunks(b: &[u8]) -> Embedded {
/// WebP RIFF chunks `EXIF`, `XMP `, `ICCP`.
pub fn webp_chunks(b: &[u8]) -> Embedded {
let mut out = Embedded::default();
let Some(size_bytes) = b.get(4..8) else {
out.chunk_walk_incomplete = true;
return out;
};
let Ok(size_bytes) = <[u8; 4]>::try_from(size_bytes) else {
out.chunk_walk_incomplete = true;
return out;
};
let riff_size = u32::from_le_bytes(size_bytes) as usize;
let Some(riff_end) = 8usize.checked_add(riff_size) else {
out.chunk_walk_incomplete = true;
return out;
};
if riff_size < 4 || riff_end < 12 {
out.chunk_walk_incomplete = true;
return out;
}
if riff_end != b.len() {
out.chunk_walk_incomplete = true;
}
let end = riff_end.min(b.len());
let mut i = 12usize;
while i + 8 <= b.len() {
let id = &b[i..i + 4];
let len = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]) as usize;
let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break };
while i < end {
let Some(header_end) = i.checked_add(8).filter(|next| *next <= end) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(header) = b.get(i..header_end) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(length_bytes) = header.get(4..8) else {
out.chunk_walk_incomplete = true;
break;
};
let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else {
out.chunk_walk_incomplete = true;
break;
};
let len = u32::from_le_bytes(length_bytes) as usize;
let Some(data_end) = header_end.checked_add(len) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(chunk_end) = data_end.checked_add(len & 1).filter(|next| *next <= end) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(id) = header.get(..4) else {
out.chunk_walk_incomplete = true;
break;
};
let Some(data) = b.get(header_end..data_end) else {
out.chunk_walk_incomplete = true;
break;
};
match id {
b"EXIF" => out.exif = Some(crate::strip_exif_header(data).to_vec()),
b"XMP " => out.xmp = Some(String::from_utf8_lossy(data).into_owned()),
b"ICCP" => out.icc = Some(data.to_vec()),
_ => {}
}
i += 8 + len + (len & 1);
i = chunk_end;
}
out
}
@@ -258,7 +484,7 @@ pub fn webp_chunks(b: &[u8]) -> Embedded {
mod tests {
use super::*;
use crate::exif::tests::sample_exif;
use craft_tiff::ByteOrder;
use craft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
fn seg(marker: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![0xff, marker];
@@ -329,6 +555,150 @@ mod tests {
assert_eq!(m.caption.as_deref(), Some("A caption"));
}
fn jpeg_with_orientation(exif_orientation: Option<crate::Orientation>, xmp_orientation: &str) -> Vec<u8> {
let mut jpeg = vec![0xff, 0xd8];
if let Some(orientation) = exif_orientation {
let mut exif = b"Exif\0\0".to_vec();
exif.extend(crate::exif::write_exif(&crate::Metadata { orientation: Some(orientation), ..Default::default() }));
jpeg.extend(seg(0xe1, &exif));
}
let packet = format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:tiff="http://ns.adobe.com/tiff/1.0/" tiff:Orientation="{xmp_orientation}"/></rdf:RDF>"#
);
let mut xmp = XMP_SIG.to_vec();
xmp.extend_from_slice(packet.as_bytes());
jpeg.extend(seg(0xe1, &xmp));
jpeg.extend_from_slice(&[0xff, 0xd9]);
jpeg
}
#[test]
fn embedded_xmp_orientation_requires_integral_value_and_respects_exif() {
let malformed = crate::extract(&jpeg_with_orientation(None, "6.9"));
assert_eq!(malformed.orientation, None);
let malformed_xmp_with_exif = crate::extract(&jpeg_with_orientation(Some(crate::Orientation::Rotate180), "6.5"));
assert_eq!(malformed_xmp_with_exif.orientation.map(|orientation| orientation.to_exif()), Some(3));
let valid_xmp = crate::extract(&jpeg_with_orientation(None, "6"));
assert_eq!(valid_xmp.orientation.map(|orientation| orientation.to_exif()), Some(6));
let valid_exif = crate::extract(&jpeg_with_orientation(Some(crate::Orientation::Rotate180), "6"));
assert_eq!(valid_exif.orientation.map(|orientation| orientation.to_exif()), Some(3));
}
#[test]
fn jpeg_invalid_xmp_iso_stays_absent_and_exif_iso_wins() {
let make_jpeg = |exif: Option<Vec<u8>>, xmp: &str| {
let mut jpeg = vec![0xff, 0xd8];
if let Some(exif) = exif {
let mut payload = b"Exif\0\0".to_vec();
payload.extend(exif);
jpeg.extend(seg(0xe1, &payload));
}
let mut payload = XMP_SIG.to_vec();
payload.extend_from_slice(xmp.as_bytes());
jpeg.extend(seg(0xe1, &payload));
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]);
jpeg
};
let invalid = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:ISOSpeedRatings="0.5"/></rdf:RDF>"#;
assert_eq!(crate::extract(&make_jpeg(None, invalid)).iso, None);
let valid = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:ISOSpeedRatings="200"/></rdf:RDF>"#;
assert_eq!(crate::extract(&make_jpeg(Some(sample_exif(ByteOrder::Big)), valid)).iso, Some(400));
}
#[test]
fn jpeg_rejected_xmp_dimensions_keep_exif_dimensions_and_other_metadata() {
let mut jpeg = vec![0xff, 0xd8];
let mut exif = b"Exif\0\0".to_vec();
exif.extend(crate::exif::tests::sample_exif(ByteOrder::Big));
jpeg.extend(seg(0xe1, &exif));
let packet = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/"
tiff:ImageWidth="640.9" exif:PixelYDimension="8000" dc:title="Retained"/>
</rdf:RDF>"#;
let mut xmp = XMP_SIG.to_vec();
xmp.extend_from_slice(packet.as_bytes());
jpeg.extend(seg(0xe1, &xmp));
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]);
let metadata = crate::extract(&jpeg);
assert_eq!(metadata.width, Some(6000));
assert_eq!(metadata.height, Some(4000));
assert_eq!(metadata.title.as_deref(), Some("Retained"));
}
#[test]
fn jpeg_rejected_xmp_dimension_falls_back_during_whole_file_extract() {
let mut jpeg = vec![0xff, 0xd8];
let packet = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/"
tiff:ImageWidth="640.9" exif:PixelXDimension="800"/>
</rdf:RDF>"#;
let mut xmp = XMP_SIG.to_vec();
xmp.extend_from_slice(packet.as_bytes());
jpeg.extend(seg(0xe1, &xmp));
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]);
assert_eq!(crate::extract(&jpeg).width, Some(800));
}
#[test]
fn jpeg_invalid_iptc_time_created_allows_xmp_and_exif_fallbacks() {
fn iim() -> Vec<u8> {
let mut data = Vec::new();
for (dataset, value) in [(55, &b"20260101"[..]), (60, b"121314+01"), (5, b"retained title")] {
data.extend_from_slice(&[0x1c, 2, dataset]);
data.extend_from_slice(&(value.len() as u16).to_be_bytes());
data.extend_from_slice(value);
}
data
}
fn jpeg(xmp: &str, exif: Option<&[u8]>) -> Vec<u8> {
let mut jpeg = vec![0xff, 0xd8];
if let Some(exif) = exif {
let mut payload = b"Exif\0\0".to_vec();
payload.extend_from_slice(exif);
jpeg.extend_from_slice(&seg(0xe1, &payload));
}
let mut payload = XMP_SIG.to_vec();
payload.extend_from_slice(xmp.as_bytes());
jpeg.extend_from_slice(&seg(0xe1, &payload));
let mut resource = b"Photoshop 3.0\0".to_vec();
resource.extend_from_slice(b"8BIM\x04\x04\0\0");
let iim = iim();
resource.extend_from_slice(&(iim.len() as u32).to_be_bytes());
resource.extend_from_slice(&iim);
if iim.len() % 2 == 1 {
resource.push(0);
}
jpeg.extend_from_slice(&seg(0xed, &resource));
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]);
jpeg
}
let xmp = crate::write_xmp(&crate::Metadata {
capture_time: Some(crate::DateTime { year: 2026, month: 1, day: 2, hour: 10, offset_minutes: Some(0), ..Default::default() }),
..Default::default()
});
let fallback = jpeg(&xmp, None);
let embedded = embedded(&fallback);
assert!(embedded.iptc.is_some(), "raw IIM remains available");
assert_eq!(crate::extract(&fallback).capture_time.map(|time| time.to_iso()).as_deref(), Some("2026-01-02T10:00:00+00:00"));
assert_eq!(crate::extract(&fallback).title.as_deref(), Some("retained title"));
let mut exif_ifd = IfdBuilder::new();
exif_ifd.set(0x9003, Value::Ascii("2026:01:03 11:12:13".into()));
let mut ifd0 = IfdBuilder::new();
ifd0.set_child(0x8769, exif_ifd);
let exif = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
let exif_precedence = jpeg(&xmp, Some(&exif));
assert_eq!(crate::extract(&exif_precedence).capture_time.map(|time| time.to_iso()).as_deref(), Some("2026-01-03T11:12:13"));
}
#[test]
fn jpeg_extended_xmp() {
let mut j = vec![0xff, 0xd8];
@@ -341,6 +711,28 @@ mod tests {
j.extend(seg(0xe1, &p));
}
assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld"));
assert_eq!(jpeg_segments(&j).xmp_extended_guid, Some("A".repeat(32)));
}
/// A GUID mix, a short chunk set or a wrong declared length leaves the extension unverified.
#[test]
fn jpeg_extended_xmp_guid_needs_matching_segments_and_length() {
let build = |segments: &[(u8, u32, u32, &[u8])]| {
let mut j = vec![0xff, 0xd8];
for &(guid, full, off, part) in segments {
let mut p = XMP_EXT_SIG.to_vec();
p.extend_from_slice(&[guid; 32]);
p.extend_from_slice(&full.to_be_bytes());
p.extend_from_slice(&off.to_be_bytes());
p.extend_from_slice(part);
j.extend(seg(0xe1, &p));
}
jpeg_segments(&j).xmp_extended_guid
};
assert!(build(&[(b'A', 10, 0, b"hello"), (b'B', 10, 5, b"world")]).is_none(), "two GUIDs");
assert!(build(&[(b'A', 10, 0, b"hello")]).is_none(), "half the declared length");
assert!(build(&[(b'A', 9, 0, b"hello"), (b'A', 9, 5, b"world")]).is_none(), "longer than declared");
assert!(build(&[(b'A', 10, 0, b"hello"), (b'A', 10, 4, b"oworl")]).is_none(), "overlapping chunks");
}
#[test]
@@ -404,6 +796,7 @@ mod tests {
let m = crate::extract(&p);
assert_eq!(m.iso, Some(400));
assert_eq!(m.label.as_deref(), Some("Red"));
assert!(!embedded(&p).chunk_walk_incomplete);
let mut w = b"RIFF\0\0\0\0WEBP".to_vec();
let mut body = |id: &[u8], data: &[u8]| {
@@ -419,11 +812,30 @@ mod tests {
let mut ex = b"Exif\0\0".to_vec();
ex.extend_from_slice(&exif);
body(b"EXIF", &ex);
let riff_size = w.len().saturating_sub(8) as u32;
w[4..8].copy_from_slice(&riff_size.to_le_bytes());
let e = webp_chunks(&w);
assert!(!e.chunk_walk_incomplete);
assert_eq!(e.icc.as_deref(), Some(&b"abc"[..]));
assert_eq!(crate::extract(&w).f_number, Some(2.8));
}
#[test]
fn chunk_walk_reports_truncated_png_and_webp() {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend_from_slice(&32u32.to_be_bytes());
png.extend_from_slice(b"iTXtshort");
assert!(png_chunks(&png).chunk_walk_incomplete);
let mut webp = b"RIFF\0\0\0\0WEBP".to_vec();
webp.extend_from_slice(b"XMP ");
webp.extend_from_slice(&32u32.to_le_bytes());
webp.extend_from_slice(b"short");
let riff_size = webp.len().saturating_sub(8).saturating_add(27) as u32;
webp[4..8].copy_from_slice(&riff_size.to_le_bytes());
assert!(webp_chunks(&webp).chunk_walk_incomplete);
}
#[test]
fn tiff_file_is_its_own_exif() {
let t = sample_exif(ByteOrder::Big);
+24 -3
View File
@@ -107,7 +107,8 @@ impl DateTime {
_ => return None,
};
let (h, m) = match rest.split_once(':') {
Some((h, m)) => (num(h)?, num(m)?),
Some((h, m)) if m.len() == 2 => (num(h)?, num(m)?),
Some(_) => return None,
None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?),
None => (num(rest)?, 0),
};
@@ -159,8 +160,13 @@ impl DateTime {
None => (t, None),
};
let mut cp = clock.split(':');
dt.hour = num(cp.next()?)?.try_into().ok()?;
dt.minute = num(cp.next()?)?.try_into().ok()?;
let hour = cp.next()?;
let minute = cp.next()?;
if hour.len() != 2 || minute.len() != 2 {
return None;
}
dt.hour = num(hour)?.try_into().ok()?;
dt.minute = num(minute)?.try_into().ok()?;
if let Some(sec) = cp.next() {
let (whole, fraction) = match sec.split_once('.') {
Some((whole, fraction)) => {
@@ -233,6 +239,14 @@ impl DateTime {
mod tests {
use super::*;
#[test]
fn offset_rejects_short_minute_field() {
assert_eq!(DateTime::parse_offset("+08:5"), None);
assert_eq!(DateTime::parse_offset("+1:2"), None);
assert_eq!(DateTime::parse_offset("+08:05"), Some(485));
assert_eq!(DateTime::parse_offset("-05:30"), Some(-330));
}
#[test]
fn exif_forms() {
let d = DateTime::parse_exif("2024:06:01 13:45:59").unwrap();
@@ -367,6 +381,13 @@ mod tests {
}
}
#[test]
fn iso_rejects_single_digit_clock_fields() {
for value in ["2024-06-01T1:02:03Z", "2024-06-01T01:2:03Z"] {
assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}");
}
}
#[test]
fn iso_validates_clock_while_preserving_supported_forms() {
assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019");
+59 -2
View File
@@ -3,7 +3,8 @@
//!
//! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool
//! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and
//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read.
//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box, then on the public raw.pixls.us
//! samples of every other body in [`SONY_MODELS`]. No third-party code was read.
//!
//! | Tag | Layout (confirmed) | Use |
//! |---|---|---|
@@ -79,7 +80,63 @@ impl FocusPoint {
/// Sony bodies whose focus tags have been checked against real files. Another body may store the same
/// tags differently (or not mean the same by them): it shows no focus point until its files are checked.
pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"];
///
/// The α1 and A7S III on 333 files of a photographer's library; the others on the public raw.pixls.us samples (one to
/// six ARWs per body, 103 files of the 44 bodies): every point equal to the one ExifTool's FocusLocation and
/// FocusFrameSize give (77 points, one of them rotated), every refusal (26: manual focus, Wide at the exact centre)
/// equal to what ExifTool's focus modes say, and each body's box drawn on its camera JPEG on the subject it
/// focused on (a pigeon, a face, a leaf, a chart patch). Seven of them (DSC-HX95, DSC-HX99, ILCE-6700, ILCE-7CR,
/// ILCE-7RM2, ILCE-7SM2, ILCE-9M3) only have samples without a point, read as ExifTool reads them. Not listed: the
/// bodies without `0x2027` (every NEX, SLT and DSLR-A; ILCE-3000, 3500, 5000, 5100, 6000 and 6001, QX1, 7, 7R, 7S
/// and 7M2; ILCA-68 and 77M2; RX100 to RX100 III, RX10, RX1 and RX1R; UMC-R10C), and the DSC-RX0, RX100 IV, RX100 V,
/// RX10 II, RX10 III and RX1R II, whose `0x201b` reads 0 (Manual) while the enciphered block says AF-S: ExifTool
/// doesn't decode that tag for them.
pub const SONY_MODELS: &[&str] = &[
"DSC-HX95",
"DSC-HX99",
"DSC-RX0M2",
"DSC-RX100M5A",
"DSC-RX100M6",
"DSC-RX100M7",
"DSC-RX100M7A",
"DSC-RX10M4",
"DSC-RX1RM3",
"ILCA-99M2",
"ILCE-1",
"ILCE-1M2",
"ILCE-6100",
"ILCE-6300",
"ILCE-6400",
"ILCE-6400A",
"ILCE-6500",
"ILCE-6600",
"ILCE-6700",
"ILCE-7C",
"ILCE-7CM2",
"ILCE-7CR",
"ILCE-7M3",
"ILCE-7M4",
"ILCE-7M5",
"ILCE-7RM2",
"ILCE-7RM3",
"ILCE-7RM3A",
"ILCE-7RM4",
"ILCE-7RM4A",
"ILCE-7RM5",
"ILCE-7SM2",
"ILCE-7SM3",
"ILCE-9",
"ILCE-9M2",
"ILCE-9M3",
"ILME-FX2",
"ILME-FX3",
"ILME-FX30",
"ZV-1",
"ZV-1M2",
"ZV-E1",
"ZV-E10",
"ZV-E10M2",
];
/// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is
/// one whose files can carry a focus point, so a photo from any other camera is never opened to look.
+10 -5
View File
@@ -261,8 +261,11 @@ fn read_exif_finds_it_through_the_maker_note_and_orientation() {
// EXIF 8: sensor x becomes 1 - upright y
let (cx, cy) = f.center();
assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}");
// an unconfirmed body or another maker: nothing
assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none());
// a body checked on public samples reads the same layout
let f = crate::read_exif(&sony_file("SONY", "ZV-E10", 1, &alpha1_entries())).focus.expect("ZV-E10 focus point");
assert!((f.center().0 - 3226.0 / 8640.0).abs() < 1e-9, "{:?}", f.center());
// a body left out (its 0x201b is not the focus mode) or another maker: nothing
assert!(crate::read_exif(&sony_file("SONY", "DSC-RX100M4", 1, &alpha1_entries())).focus.is_none());
assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none());
}
@@ -339,7 +342,7 @@ fn header_read_matches_the_whole_file_and_reads_little() {
assert_eq!(r.focus, whole);
assert!(r.bytes_read <= HEADER_PREFIX);
// unsupported bodies agree too (no point either way)
let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries());
let other = sony_file("SONY", "DSC-RX100M4", 1, &alpha1_entries());
assert_eq!(read(&other).focus, None);
}
@@ -396,8 +399,10 @@ fn the_synthetic_arw_is_laid_out_as_described() {
#[test]
fn cameras_that_can_have_a_point() {
assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 "));
for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] {
for c in ["SONY ILCE-1", "Sony ILCE-7SM3 ", "SONY ILCE-1M2", "SONY ILCE-7M4", "SONY ZV-E10", "SONY DSC-RX100M7"] {
assert!(camera_supported(c), "{c}");
}
for c in ["SONY ILCE-7M2", "SONY DSC-RX100M4", "SONY NEX-7", "Canon EOS R5", "", "SONY", "ILCE-1", "SON", "SONY ILCE-1M"] {
assert!(!camera_supported(c), "{c}");
}
}
+91 -4
View File
@@ -7,6 +7,10 @@
//! routes (`<rte>`) carry no timeline and are ignored. The schema defines a track segment as one
//! continuous span of logging, so positions are only interpolated *within* a segment, never across
//! the gap where reception was lost or the logger was off.
//!
//! Each point also records which `<trk>` it came from ([`TrackPoint::track`], with the tracks'
//! `<name>`s in [`Tracklog::names`]), so a map can draw the tracks of a file apart and tag from
//! one of them ([`Tracklog::only_track`]).
use quick_xml::events::Event;
@@ -15,6 +19,10 @@ use crate::{DateTime, Gps};
/// Nesting / size limits (a track log is flat; these only stop hostile input).
const MAX_DEPTH: usize = 64;
const MAX_POINTS: usize = 5_000_000;
/// Most tracks (`<trk>`) in one file.
const MAX_TRACKS: usize = 10_000;
/// Longest track name kept (characters); the rest is cut.
const MAX_NAME_CHARS: usize = 200;
/// One timed point of a track.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -27,6 +35,8 @@ pub struct TrackPoint {
pub elevation: Option<f64>,
/// Track segment number, counted across the whole file (continuous logging spans).
pub segment: u32,
/// The `<trk>` it belongs to, counted from 0 in file order.
pub track: u32,
}
/// How a position was found by [`Tracklog::locate`].
@@ -44,6 +54,8 @@ pub struct Tracklog {
pub points: Vec<TrackPoint>,
/// Tracks (`<trk>`) in the file.
pub tracks: usize,
/// Each track's `<name>` (empty when it has none), in file order: `names.len() == tracks`.
pub names: Vec<String>,
/// Track points without a usable time (left out of `points`).
pub untimed: usize,
}
@@ -120,7 +132,13 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
}
match name.as_slice() {
b"gpx" if path.is_empty() => seen_gpx = true,
b"trk" if parent == Some(b"gpx") => log.tracks += 1,
b"trk" if parent == Some(b"gpx") => {
if log.tracks >= MAX_TRACKS {
return Err(GpxError::TooComplex);
}
log.tracks += 1;
log.names.push(String::new());
}
b"trkseg" if parent == Some(b"trk") && log.points.last().is_some_and(|p| p.segment == segment) => segment += 1,
b"trkpt" if parent == Some(b"trkseg") => {
let mut p = Pending::default();
@@ -182,6 +200,11 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
}
}
b"trkpt" => finish_point(&mut log, pt.take(), segment)?,
b"name" if parent == Some(b"trk") => {
if let Some(n) = log.names.last_mut() {
*n = text.trim().chars().take(MAX_NAME_CHARS).collect();
}
}
_ => {}
}
text.clear();
@@ -214,7 +237,9 @@ fn finish_point(log: &mut Tracklog, p: Option<Pending>, segment: u32) -> Result<
if log.points.len() >= MAX_POINTS {
return Err(GpxError::TooComplex);
}
log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation: p.ele, segment });
let track = u32::try_from(log.tracks.saturating_sub(1)).unwrap_or(u32::MAX);
let elevation = p.ele.filter(|e| e.is_finite());
log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation, segment, track });
Ok(())
}
@@ -224,6 +249,16 @@ impl Tracklog {
Some((self.points.first()?.time, self.points.last()?.time))
}
/// The points of track `index` alone (its segments as they were), or `None` when there is no
/// such track.
pub fn only_track(&self, index: usize) -> Option<Tracklog> {
if index >= self.tracks {
return None;
}
let points = self.points.iter().filter(|p| usize::try_from(p.track).ok() == Some(index)).copied().collect();
Some(Tracklog { points, tracks: 1, names: vec![self.names.get(index).cloned().unwrap_or_default()], untimed: 0 })
}
/// Where the logger was at `time` (seconds since the epoch, UTC).
///
/// Between two points of the same segment that are at most `max_gap` seconds apart, the position
@@ -458,8 +493,8 @@ mod tests {
#[test]
fn interpolation_across_the_antimeridian() {
let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 };
let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0 };
let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0, track: 0 };
let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0, track: 0 };
let g = lerp(&a, &b, 0.75);
assert!((g.longitude - -179.5).abs() < 1e-9, "{g:?}");
}
@@ -487,5 +522,57 @@ mod tests {
);
let deep = "<gpx>".to_string() + &"<a>".repeat(100);
assert_eq!(parse_gpx(&deep), Err(GpxError::TooComplex));
let many = "<gpx>".to_string() + &"<trk/>".repeat(MAX_TRACKS + 1) + "</gpx>";
assert_eq!(parse_gpx(&many), Err(GpxError::TooComplex));
}
#[test]
fn tracks_are_told_apart_with_their_names() {
let s = r#"<gpx version="1.1"><trk><name> Morning </name><trkseg>
<trkpt lat="1" lon="1"><time>2026-01-01T10:00:00Z</time></trkpt>
<trkpt lat="1.1" lon="1.1"><time>2026-01-01T10:01:00Z</time></trkpt>
</trkseg></trk>
<trk><trkseg/></trk>
<trk><trkseg>
<trkpt lat="2" lon="2"><time>2026-01-01T09:00:00Z</time></trkpt>
</trkseg></trk></gpx>"#;
let log = parse_gpx(s).unwrap();
assert_eq!(log.tracks, 3);
assert_eq!(log.names, vec!["Morning".to_string(), String::new(), String::new()]);
// sorted by time: the third track's point comes first
assert_eq!(log.points.iter().map(|p| p.track).collect::<Vec<_>>(), vec![2, 0, 0]);
let first = log.only_track(0).unwrap();
assert_eq!((first.points.len(), first.tracks, first.names[0].as_str()), (2, 1, "Morning"));
assert!(log.only_track(1).unwrap().points.is_empty(), "an empty track");
assert!(log.only_track(3).is_none());
// a different track is a different segment: never interpolated across
assert_ne!(log.points[0].segment, log.points[1].segment);
}
#[test]
fn hostile_points_are_left_out() {
let s = r#"<gpx><trk><name>xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</name><trkseg>
<trkpt lat="NaN" lon="1"><time>2026-01-01T10:00:00Z</time></trkpt>
<trkpt lat="1" lon="inf"><time>2026-01-01T10:00:01Z</time></trkpt>
<trkpt lat="91" lon="1"><time>2026-01-01T10:00:02Z</time></trkpt>
<trkpt lat="1" lon="-180.5"><time>2026-01-01T10:00:03Z</time></trkpt>
<trkpt lat="90" lon="180"><ele>NaN</ele><time>2026-01-01T10:00:04Z</time></trkpt>
<trkpt lat="-90" lon="-180"><time>not a time</time></trkpt>
<trkpt lat="5" lon="5"><time>2026-01-01T10:00:05Z</time></trkpt>
<trkpt lat="6" lon="6"><time>2026-01-01T09:00:00Z</time></trkpt>
</trkseg></trk></gpx>"#;
let log = parse_gpx(s).unwrap();
assert_eq!(log.points.len(), 3, "{:?}", log.points);
assert_eq!(log.untimed, 1);
assert!(log.points.iter().all(|p| p.latitude.is_finite() && p.longitude.is_finite()));
assert_eq!((log.points[1].latitude, log.points[1].elevation), (90.0, None), "a NaN elevation is no elevation");
// out of order in the file, in order in the log
assert_eq!((log.points[0].latitude, log.points[2].latitude), (6.0, 5.0));
assert_eq!(log.names[0].chars().count(), MAX_NAME_CHARS);
// a single point: located at its own time only (within the gap)
let one = parse_gpx(r#"<gpx><trk><trkseg><trkpt lat="1" lon="2"><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>"#).unwrap();
assert_eq!(one.span(), Some((one.points[0].time, one.points[0].time)));
assert!(one.locate(one.points[0].time + 10.0, 60.0).is_some());
assert!(one.locate(f64::NAN, 60.0).is_none());
}
}
+117 -10
View File
@@ -13,6 +13,67 @@ fn decode(b: &[u8], utf8: bool) -> String {
}
}
fn decimal_pair(bytes: &[u8]) -> Option<u16> {
let [tens, ones] = bytes else { return None };
if !tens.is_ascii_digit() || !ones.is_ascii_digit() {
return None;
}
Some(u16::from(*tens - b'0') * 10 + u16::from(*ones - b'0'))
}
fn parse_created(date: Option<&str>, time: Option<&str>) -> Option<DateTime> {
let date = date.filter(|value| value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit()))?;
let time = time.filter(|value| !value.is_empty());
if let Some(time) = time {
let bytes = time.as_bytes();
let clock = bytes.get(..6)?;
if !clock.iter().all(|byte| byte.is_ascii_digit()) {
return None;
}
let hour = decimal_pair(clock.get(..2)?)?;
let minute = decimal_pair(clock.get(2..4)?)?;
let second = decimal_pair(clock.get(4..6)?)?;
if hour > 23 || minute > 59 || second > 59 {
return None;
}
match bytes.len() {
// IPTC-IIM permits a local wall-clock form, but without an offset
// it is ambiguous; retain the date and omit this time.
6 => {}
11 => {
let offset = bytes.get(6..11)?;
if !matches!(offset.first().copied(), Some(b'+' | b'-')) || !offset.get(1..)?.iter().all(|byte| byte.is_ascii_digit()) {
return None;
}
let offset_hour = decimal_pair(offset.get(1..3)?)?;
let offset_minute = decimal_pair(offset.get(3..5)?)?;
if offset_hour > 18 || offset_minute > 59 || (offset_hour == 18 && offset_minute != 0) {
return None;
}
}
_ => return None,
}
}
let mut iso = format!("{}-{}-{}", date.get(..4)?, date.get(4..6)?, date.get(6..8)?);
if let Some(time) = time.filter(|time| time.len() == 11) {
let bytes = time.as_bytes();
iso.push('T');
iso.push_str(time.get(..2)?);
iso.push(':');
iso.push_str(time.get(2..4)?);
iso.push(':');
iso.push_str(time.get(4..6)?);
if bytes.len() == 11 {
let offset = time.get(6..)?;
iso.push_str(offset.get(..3)?);
iso.push(':');
iso.push_str(offset.get(3..)?);
}
}
DateTime::parse_iso(&iso)
}
/// Parse an IIM stream into the fields [`Metadata`] carries (title, keywords, artist, copyright, caption,
/// creation date/time).
pub fn parse_iptc(b: &[u8]) -> Metadata {
@@ -57,16 +118,7 @@ pub fn parse_iptc(b: &[u8]) -> Metadata {
}
i = at + size;
}
if let Some(d) = date.filter(|d| d.len() == 8 && d.bytes().all(|c| c.is_ascii_digit())) {
let mut s = format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..8]);
if let Some(t) = time.filter(|t| t.is_ascii() && t.len() >= 6 && t[..6].bytes().all(|c| c.is_ascii_digit())) {
s += &format!("T{}:{}:{}", &t[..2], &t[2..4], &t[4..6]);
if t.len() >= 11 {
s += &format!("{}:{}", &t[6..9], &t[9..11]);
}
}
m.capture_time = DateTime::parse_iso(&s);
}
m.capture_time = parse_created(date.as_deref(), time.as_deref());
m
}
@@ -119,6 +171,61 @@ pub(crate) mod tests {
assert_eq!(m.capture_time.unwrap().to_iso(), "2021-03-04T10:11:12+01:00");
}
fn capture_time(time: Option<&[u8]>) -> Metadata {
let mut iim = Vec::new();
ds(&mut iim, 2, 55, b"20260101");
if let Some(time) = time {
ds(&mut iim, 2, 60, time);
}
ds(&mut iim, 2, 5, b"kept title");
ds(&mut iim, 2, 25, b"kept keyword");
parse_iptc(&iim)
}
#[test]
fn iptc_time_created_rejects_incomplete_or_trailing_text() {
for time in [
&b"121314+01"[..],
b"121314junk",
b"12A314",
b"12131499",
b"121314+0100junk",
b"121314+",
b"121314+010",
b"121314+01000",
b"12345",
b"240000",
b"250000+0100",
b"126100+0100",
b"235960",
b"235960+0000",
b"121314+1801",
] {
let metadata = capture_time(Some(time));
assert_eq!(metadata.capture_time, None, "accepted malformed Time Created {time:?}");
assert_eq!(metadata.title.as_deref(), Some("kept title"), "unrelated metadata was lost for {time:?}");
assert_eq!(metadata.keywords, ["kept keyword"], "unrelated metadata was lost for {time:?}");
}
}
#[test]
fn iptc_time_created_preserves_supported_forms() {
let cases: &[(Option<&[u8]>, Option<&str>)] = &[
(None, Some("2026-01-01T00:00:00")),
(Some(b""), Some("2026-01-01T00:00:00")),
(Some(b" \t"), Some("2026-01-01T00:00:00")),
(Some(b"121314"), Some("2026-01-01T00:00:00")),
(Some(b"121314+0100"), Some("2026-01-01T12:13:14+01:00")),
(Some(b"121314-0530"), Some("2026-01-01T12:13:14-05:30")),
(Some(b"121314+1800"), Some("2026-01-01T12:13:14+18:00")),
(Some(b" 121314+0000 \0"), Some("2026-01-01T12:13:14+00:00")),
];
for (time, expected) in cases {
let metadata = capture_time(*time);
assert_eq!(metadata.capture_time.map(|value| value.to_iso()).as_deref(), *expected, "Time Created {time:?}");
}
}
#[test]
fn non_utf8_text_drops_trailing_nul() {
let mut v = Vec::new();
+381 -3
View File
@@ -36,7 +36,7 @@ pub mod tags;
mod xmp;
mod xmp_merge;
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
pub use container::{Embedded, embedded, jpeg_segments, jpeg_segments_with_all_xmp, png_chunks, webp_chunks};
pub use datetime::{DatePrecision, DateTime};
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif};
pub use focus::FocusPoint;
@@ -45,7 +45,9 @@ pub use geom::{Orientation, Point, Rect};
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
pub use iptc::parse_iptc;
pub use tags::{TagRow, file_tag_rows, tag_rows};
pub use xmp::{CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with};
pub use xmp::{
CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_gps_position, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with,
};
pub use xmp_merge::{MergeRules, merge_xmp};
use serde::{Deserialize, Serialize};
@@ -330,7 +332,7 @@ pub fn extract(bytes: &[u8]) -> Metadata {
/// "unreadable" uses [`try_read_exif`] and [`parse_xmp`] on the blocks first.
pub fn from_embedded(e: &Embedded) -> Metadata {
let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default();
let xmp = e.xmp.as_deref().and_then(|xmp| parse_xmp(xmp).ok());
let xmp = embedded_xmp(e).unwrap_or_else(|error| error.standard.map(|x| *x));
// the size the Exif states: a size only the XMP states may be the upright one, so it can't tell how the photo is stored
let stored = m.width.zip(m.height);
// and the orientation the Exif states: the photo is displayed by that alone, so an orientation only the XMP
@@ -353,6 +355,54 @@ pub fn from_embedded(e: &Embedded) -> Metadata {
m
}
/// The XMP of `e` read as one packet: the standard packet, with the Extended XMP it links to
/// (`xmpNote:HasExtendedXMP`) merged in (see [`xmp::parse_xmp_pair`]). An error when the standard
/// packet is not XMP, when its link is malformed, when the linked extension is missing or
/// unreadable ([`Embedded::xmp_extended_error`]), or when there is an extension nothing links to:
/// a caller that must see all of a file's metadata (to prove it carries no location, say) refuses
/// the file then. [`from_embedded`] falls back to [`EmbeddedXmpError::standard`].
pub fn embedded_xmp(e: &Embedded) -> Result<Option<XmpData>, EmbeddedXmpError> {
let fail = |standard: Option<XmpData>, message: String| EmbeddedXmpError { standard: standard.map(Box::new), message };
let Some(packet) = e.xmp.as_deref() else {
return match (&e.xmp_extended, &e.xmp_extended_error) {
(None, None) => Ok(None),
_ => Err(fail(None, "Extended XMP is present without a standard packet".into())),
};
};
let parsed = parse_xmp(packet).map_err(|error| fail(None, format!("embedded XMP is invalid: {error}")))?;
let mut standard = parsed.clone();
standard.properties.remove("xmpNote:HasExtendedXMP");
standard.values.remove("xmpNote:HasExtendedXMP");
let reference = xmp::extended_xmp_reference(&parsed).map_err(|error| fail(Some(standard.clone()), error))?;
match (reference, e.xmp_extended.as_deref()) {
(Some(guid), Some(extended)) if e.xmp_extended_guid.as_deref() == Some(guid.as_str()) => xmp::parse_xmp_pair(packet, extended, &[])
.map(Some)
.map_err(|error| fail(Some(standard), format!("the linked Extended XMP is invalid: {error}"))),
(Some(_), _) => {
let why = e.xmp_extended_error.clone().unwrap_or_else(|| "the linked Extended XMP is missing".into());
Err(fail(Some(standard), why))
}
(None, Some(_)) => Err(fail(Some(standard), "Extended XMP is present but the standard packet does not link it".into())),
(None, None) => Ok(Some(standard)),
}
}
/// Why [`embedded_xmp`] could not read a file's XMP whole.
#[derive(Clone, Debug, PartialEq)]
pub struct EmbeddedXmpError {
/// The standard packet, when it could be read: what a best-effort reader uses.
pub standard: Option<Box<XmpData>>,
pub message: String,
}
impl std::fmt::Display for EmbeddedXmpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for EmbeddedXmpError {}
/// Turn regions drawn on a photo as it is stored onto the upright photo. MWG gives each box as a fraction of
/// `AppliedToDimensions` (`drawn_on`); for a photo whose Exif `orientation` turns it a quarter turn, some apps give the
/// stored, sideways size there and boxes on the stored pixels to match (Lightroom says so per box with
@@ -383,6 +433,20 @@ pub fn lens_spec_name(spec: [f64; 4]) -> Option<String> {
Some(format!("{focal} mm f/{aperture}"))
}
/// Whether `s` has the `<focal> mm f/<aperture>` shape written by [`lens_spec_name`].
pub fn is_lens_spec_name(s: &str) -> bool {
let Some((focal, aperture)) = s.trim().split_once(" mm f/") else { return false };
let is_num_or_range = |part: &str| {
let mut pieces = part.split('-');
let Some(first) = pieces.next() else { return false };
let second = pieces.next();
pieces.next().is_none()
&& first.parse::<f64>().is_ok_and(|v| v.is_finite() && v > 0.0)
&& second.is_none_or(|b| b.parse::<f64>().is_ok_and(|v| v.is_finite() && v > 0.0))
};
is_num_or_range(focal) && is_num_or_range(aperture)
}
/// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`.
pub(crate) fn parse_number(s: &str) -> Option<f64> {
let s = s.trim();
@@ -408,6 +472,62 @@ mod tests {
assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None);
}
#[test]
fn lens_spec_names_are_recognised() {
for spec in [[50.0, 50.0, 1.8, 1.8], [18.0, 135.0, 3.5, 5.6]] {
assert!(is_lens_spec_name(&lens_spec_name(spec).unwrap()));
}
assert!(is_lens_spec_name(" 24.0-70.0 mm f/2.8 "));
for not in ["EF 50mm f/1.8", "50 mm f/", "0 mm f/2.8", "50-60-70 mm f/2.8", "nan mm f/2", "50 mm f/1.8-"] {
assert!(!is_lens_spec_name(not), "{not}");
}
}
#[test]
fn from_embedded_preserves_exif_iptc_xmp_precedence() {
let exif = Metadata {
make: Some("Example".into()),
model: Some("Camera".into()),
lens_model: Some("35mm F2".into()),
artist: Some("EXIF artist".into()),
orientation: Some(Orientation::from_exif(6)),
exposure_time: Some(1.0 / 125.0),
..Default::default()
};
let packet = write_xmp(&Metadata {
artist: Some("XMP artist".into()),
caption: Some("XMP caption".into()),
rating: Some(4),
keywords: vec!["xmp-keyword".into()],
..Default::default()
});
let mut iptc = Vec::new();
for (dataset, value) in [(5u8, "IPTC title"), (120u8, "IPTC caption")] {
iptc.extend_from_slice(&[0x1c, 2, dataset]);
iptc.extend_from_slice(&u16::try_from(value.len()).unwrap().to_be_bytes());
iptc.extend_from_slice(value.as_bytes());
}
let blocks = Embedded { exif: Some(write_exif(&exif)), iptc: Some(iptc), xmp: Some(packet.clone()), ..Default::default() };
let parsed = from_embedded(&blocks);
assert_eq!(parsed.make, exif.make);
assert_eq!(parsed.model, exif.model);
assert_eq!(parsed.lens_model, exif.lens_model);
assert_eq!(parsed.orientation, exif.orientation);
assert_eq!(parsed.exposure_time, exif.exposure_time);
assert_eq!(parsed.title.as_deref(), Some("IPTC title"));
assert_eq!(parsed.caption.as_deref(), Some("XMP caption"));
assert_eq!(parsed.artist.as_deref(), Some("XMP artist"));
assert_eq!(parsed.rating, Some(4));
assert_eq!(parsed.keywords, vec!["xmp-keyword"]);
assert_eq!(blocks.xmp.as_deref(), Some(packet.as_str()));
}
#[test]
fn from_embedded_ignores_damaged_blocks() {
let blocks = Embedded { exif: Some(vec![1, 2, 3]), iptc: Some(vec![0x1c, 2]), xmp: Some("<broken".into()), ..Default::default() };
assert_eq!(from_embedded(&blocks), Metadata::default());
}
#[test]
fn numbers() {
assert_eq!(parse_number("28/10"), Some(2.8));
@@ -680,4 +800,262 @@ mod tests {
assert_eq!(m.orientation, Some(Orientation::Rotate90));
assert!(near(m.regions[0].rect, DRAWN), "TIFF: {:?}", m.regions[0].rect);
}
const JPEG_XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
const JPEG_XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
fn jpeg_segment(marker: u8, payload: &[u8]) -> Vec<u8> {
let len = u16::try_from(payload.len() + 2).expect("test APP1 payload fits u16");
let mut segment = vec![0xff, marker];
segment.extend_from_slice(&len.to_be_bytes());
segment.extend_from_slice(payload);
segment
}
fn xmp_jpeg(standard: &str, guid: &str, extension: &str, chunks: &[(u32, &[u8])]) -> Vec<u8> {
let mut jpeg = vec![0xff, 0xd8];
let mut packet = JPEG_XMP_SIG.to_vec();
packet.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &packet));
for (offset, data) in chunks {
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&(extension.len() as u32).to_be_bytes());
payload.extend_from_slice(&offset.to_be_bytes());
payload.extend_from_slice(data);
jpeg.extend(jpeg_segment(0xe1, &payload));
}
jpeg
}
fn append_extended_chunk(jpeg: &mut Vec<u8>, guid: &str, total_length: u32, offset: u32, data: &[u8]) {
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&total_length.to_be_bytes());
payload.extend_from_slice(&offset.to_be_bytes());
payload.extend_from_slice(data);
jpeg.extend(jpeg_segment(0xe1, &payload));
}
fn xmp_document(body: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" {body}></rdf:Description></rdf:RDF></x:xmpmeta>"#
)
}
fn referenced_standard(guid: &str, extra: &str) -> String {
xmp_document(&format!(r#"xmlns:reference="http://ns.adobe.com/xmp/note/" reference:HasExtendedXMP="{guid}" {extra}"#))
}
fn split_chunks(packet: &str) -> [(u32, &[u8]); 2] {
let split = packet.len() / 2;
let (first, second) = packet.as_bytes().split_at(split);
[(split as u32, second), (0, first)]
}
fn raf_with_jpeg(jpeg: &[u8]) -> Vec<u8> {
let offset = 120u32;
let length = u32::try_from(jpeg.len()).expect("test JPEG fits RAF length");
let mut raf = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec();
raf.resize(offset as usize, 0);
raf[84..88].copy_from_slice(&offset.to_be_bytes());
raf[88..92].copy_from_slice(&length.to_be_bytes());
raf.extend_from_slice(jpeg);
raf
}
#[test]
fn extended_xmp_gps_reaches_extractor() {
let guid = "A1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLatitude="45,0N" exif:GPSLongitude="7,0E""#);
let standard = referenced_standard(guid, "");
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
let gps = extract(&jpeg).gps.expect("the extension GPS reaches extraction");
assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0));
}
#[test]
fn extended_xmp_caption_reaches_extractor() {
let guid = "B1234567890123456789012345678901";
let caption = "multi-chunk caption ".repeat(160);
let extension = xmp_document(&format!(r#"xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:description>{caption}</dc:description>"#));
let standard = referenced_standard(guid, "");
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
assert_eq!(extract(&jpeg).caption.as_deref(), Some(caption.trim()));
}
#[test]
fn extended_xmp_split_gps_properties_are_combined() {
let guid = "C1234567890123456789012345678901";
let standard = xmp_document(&format!(
r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}" exif:GPSLatitude="45,0N""#
));
let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLongitude="7,0E""#);
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
let gps = extract(&jpeg).gps.expect("GPS properties across packets compose");
assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0));
}
#[test]
fn extended_xmp_structured_values_are_composed() {
let guid = "F1234567890123456789012345678901";
let standard = xmp_document(&format!(
r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}"><exif:Flash rdf:parseType="Resource"><exif:Fired>True</exif:Fired></exif:Flash>"#
));
let extension =
xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/"><exif:Flash rdf:parseType="Resource"><exif:Mode>2</exif:Mode></exif:Flash>"#);
let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
assert_eq!(extract(&jpeg).flash.map(|flash| flash.raw), Some(17));
}
#[test]
fn extended_xmp_ignores_malformed_chunks_for_unreferenced_guids() {
let guid = "A1234567890123456789012345678901";
let other_guid = "B1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#);
let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension));
let mut malformed = JPEG_XMP_EXT_SIG.to_vec();
malformed.extend_from_slice(other_guid.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &malformed));
assert_eq!(extract(&jpeg).title.as_deref(), Some("Referenced title"));
assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title"));
}
#[test]
fn extended_xmp_ignores_chunk_limit_for_unreferenced_guids() {
let guid = "C1234567890123456789012345678901";
let other_guid = "D1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#);
let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension));
for _ in 0..4097 {
append_extended_chunk(&mut jpeg, other_guid, 1, 0, &[]);
}
assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title"));
}
#[test]
fn extended_xmp_ignores_received_byte_limit_for_unreferenced_guids() {
let guid = "E1234567890123456789012345678901";
let other_guid = "F1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#);
let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension));
let data = vec![0; 65_200];
for _ in 0..258 {
append_extended_chunk(&mut jpeg, other_guid, 65_200, 0, &data);
}
assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title"));
}
#[test]
fn extended_xmp_rejects_mismatched_guid_and_bad_coverage() {
let guid = "D1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Extension title""#);
let standard = referenced_standard(guid, r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#);
let bytes = extension.as_bytes();
let middle = bytes.len() / 2;
let inconsistent_lengths = {
let mut jpeg = vec![0xff, 0xd8];
let mut standard_payload = JPEG_XMP_SIG.to_vec();
standard_payload.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &standard_payload));
for (offset, declared, part) in
[(0u32, bytes.len() as u32, &bytes[..middle]), (middle as u32, (bytes.len() + 1) as u32, &bytes[middle..])]
{
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&declared.to_be_bytes());
payload.extend_from_slice(&offset.to_be_bytes());
payload.extend_from_slice(part);
jpeg.extend(jpeg_segment(0xe1, &payload));
}
jpeg
};
let oversized_length = {
let mut jpeg = vec![0xff, 0xd8];
let mut standard_payload = JPEG_XMP_SIG.to_vec();
standard_payload.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &standard_payload));
let mut payload = JPEG_XMP_EXT_SIG.to_vec();
payload.extend_from_slice(guid.as_bytes());
payload.extend_from_slice(&(16u32 * 1024 * 1024 + 1).to_be_bytes());
payload.extend_from_slice(&0u32.to_be_bytes());
payload.extend_from_slice(bytes);
jpeg.extend(jpeg_segment(0xe1, &payload));
jpeg
};
let foreign_namespace_reference = {
let foreign_standard = xmp_document(&format!(
r#"xmlns:note="urn:example:foreign-note" note:HasExtendedXMP="{guid}" xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#
));
xmp_jpeg(&foreign_standard, guid, &extension, &split_chunks(&extension))
};
let mut truncated_segment = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension));
truncated_segment.pop();
let cases = [
xmp_jpeg(&standard, "E1234567890123456789012345678901", &extension, &split_chunks(&extension)),
xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle + 1) as u32, &bytes[middle..])]),
xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle - 1) as u32, &bytes[middle..])]),
xmp_jpeg(&standard, guid, &extension, &[(0, bytes), (0, bytes)]),
xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle])]),
inconsistent_lengths,
oversized_length,
truncated_segment,
foreign_namespace_reference,
];
for (index, jpeg) in cases.iter().enumerate() {
// the last case links nothing (a foreign HasExtendedXMP): its chunks stay visible as an unlinked
// extension, as before, and embedded_xmp still refuses it below
if index + 1 < cases.len() {
assert!(embedded(jpeg).xmp_extended.is_none(), "malformed extension case {index} was exposed");
}
assert_eq!(extract(jpeg).title.as_deref(), Some("Standard title"), "standard XMP remains readable");
assert!(embedded_xmp(&embedded(jpeg)).is_err(), "embedded_xmp must reject malformed extension case {index}");
let error = embedded_xmp(&embedded(jpeg)).expect_err("invalid referenced extension is reported");
assert_eq!(error.standard.as_deref().and_then(|xmp| xmp.metadata.title.as_deref()), Some("Standard title"));
}
}
#[test]
fn standard_only_jpeg_xmp_still_extracts() {
let standard = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard-only title""#);
let mut jpeg = vec![0xff, 0xd8];
let mut packet = JPEG_XMP_SIG.to_vec();
packet.extend_from_slice(standard.as_bytes());
jpeg.extend(jpeg_segment(0xe1, &packet));
assert_eq!(extract(&jpeg).title.as_deref(), Some("Standard-only title"));
}
#[test]
fn embedded_xmp_rejects_malformed_xmp_but_extract_remains_best_effort() {
let mut jpeg = vec![0xff, 0xd8];
let mut packet = JPEG_XMP_SIG.to_vec();
packet.extend_from_slice(b"<x:xmpmeta><broken");
jpeg.extend(jpeg_segment(0xe1, &packet));
assert_eq!(extract(&jpeg), Metadata::default(), "best-effort extraction ignores invalid XMP");
assert!(embedded_xmp(&embedded(&jpeg)).is_err());
}
#[test]
fn raf_extended_xmp_errors_remain_fail_closed() {
let guid = "A1234567890123456789012345678901";
let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Extended title""#);
let standard = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#);
let unreferenced_extension = raf_with_jpeg(&xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)));
let missing_extension = raf_with_jpeg(&xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &[]));
assert_eq!(extract(&unreferenced_extension).title.as_deref(), Some("Standard title"), "best-effort reads retain standard XMP");
for (name, raf) in [("unreferenced extension", unreferenced_extension), ("missing referenced extension", missing_extension)] {
assert!(embedded_xmp(&embedded(&raf)).is_err(), "{name} must retain the JPEG validation error");
assert!(embedded_xmp(&embedded(&raf)).is_err(), "{name} must fail closed");
}
let valid = raf_with_jpeg(&xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)));
assert_eq!(extract(&valid).title.as_deref(), Some("Extended title"));
}
}
+547 -46
View File
@@ -53,6 +53,8 @@ const NAMESPACES: &[(&str, &str)] = &[
("aux", "http://ns.adobe.com/exif/1.0/aux/"),
("lr", "http://ns.adobe.com/lightroom/1.0/"),
("Iptc4xmpCore", "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"),
("Iptc4xmpExt", "http://iptc.org/std/Iptc4xmpExt/2008-02-29/"),
("xmpNote", "http://ns.adobe.com/xmp/note/"),
// Read-only: develop settings written by other raw developers.
("crs", CRS_NS),
// Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region
@@ -92,12 +94,102 @@ pub struct XmpData {
/// item order, language alternatives put `x-default` first. Foreign namespaces that reuse a recognized
/// prefix use `Q{hex-encoded namespace URI}local` instead, so they cannot impersonate known properties.
pub properties: BTreeMap<String, Vec<String>>,
/// True when flattened properties were omitted after reaching the cumulative key-size limit.
pub properties_truncated: bool,
/// Every top-level property with its full structure (arrays of structs such as local
/// corrections, which [`XmpData::properties`] flattens).
pub values: BTreeMap<String, XmpValue>,
/// The `x:xmptk` attribute of `x:xmpmeta` (older packets: `x:xaptk` of `x:xapmeta`), the name
/// of the program that wrote the packet. [`write_xmp_with`] writes [`XmpWrite::toolkit`] there.
pub toolkit: Option<String>,
/// True when the parsed tree, before flattening or merging repeated properties, names an Exif
/// GPS property anywhere or holds a place property (see `LOCATION_PROPERTIES`) with non-blank
/// text anywhere, including inside array items that [`XmpData::properties`] flattens to their
/// text and repeated structs that [`XmpData::values`] keeps only once.
pub names_location: bool,
}
/// Properties that name a place; a blank one is not a location.
const LOCATION_PROPERTIES: [&str; 7] = [
"Iptc4xmpCore:Location",
"Iptc4xmpCore:CountryCode",
"photoshop:City",
"photoshop:State",
"photoshop:Country",
"Iptc4xmpExt:LocationShown",
"Iptc4xmpExt:LocationCreated",
];
impl XmpData {
/// Whether the XMP inventory names GPS or a non-empty place field. LocationShown and
/// LocationCreated are IPTC Extension structures; their flattened array entries can be empty
/// strings even when their struct children contain a city or coordinates, so inspect their
/// retained structured value and flattened child fields as well. Callers that must prove
/// absence must check [`Self::properties_truncated`] first.
pub fn has_location_metadata(&self) -> bool {
const IPTC_LOCATION_STRUCTS: [&str; 2] = ["Iptc4xmpExt:LocationShown", "Iptc4xmpExt:LocationCreated"];
// Every flattened child of one LocationShown/LocationCreated root shares its answer.
let mut checked_roots: BTreeMap<&str, bool> = BTreeMap::new();
if self.names_location {
return true;
}
for (key, values) in &self.properties {
let parts = key.split('/');
if parts.clone().any(is_gps_name) {
// Even an isolated/blank GPS property is not proof that GPS was removed.
return true;
}
let Some(location_part) = parts.clone().find(|part| LOCATION_PROPERTIES.contains(part)) else {
continue;
};
if values.iter().any(|value| !value.trim().is_empty()) {
return true;
}
if IPTC_LOCATION_STRUCTS.contains(&location_part) {
// The scope is the path up to the location field itself, so text elsewhere in an
// enclosing struct doesn't count as a place.
let scope_len =
key.split('/').take_while(|part| *part != location_part).map(|part| part.len() + 1).sum::<usize>() + location_part.len();
let scope = key.get(..scope_len).unwrap_or(key);
let scope_has_text = *checked_roots.entry(scope).or_insert_with(|| self.location_scope_has_text(scope));
if scope_has_text {
return true;
}
}
}
false
}
/// Whether a LocationShown/LocationCreated field (a `/`-separated path from a top-level
/// property) holds text in its retained structured value or any flattened child. `values`
/// keeps the first text-bearing occurrence across RDF descriptions, while flattened child
/// properties retain every occurrence.
fn location_scope_has_text(&self, scope: &str) -> bool {
let mut parts = scope.split('/');
let structured = parts.next().and_then(|root| self.values.get(root)).and_then(|value| parts.try_fold(value, |value, part| value.field(part)));
if structured.is_some_and(value_has_text) {
return true;
}
let child_prefix = format!("{scope}/");
self.properties
.range::<str, _>((std::ops::Bound::Included(child_prefix.as_str()), std::ops::Bound::Unbounded))
.take_while(|(candidate, _)| candidate.starts_with(&child_prefix))
.any(|(_, nested_values)| nested_values.iter().any(|value| !value.trim().is_empty()))
}
/// The GUID of the JPEG Extended XMP continuation the packet announces
/// (`xmpNote:HasExtendedXMP`), matched by local name so an undeclared or foreign prefix can't
/// hide the announcement. `Some("")` when the announcement is blank.
pub fn extended_xmp_guid(&self) -> Option<&str> {
self.properties
.iter()
.find(|(key, _)| !key.contains('/') && key.rsplit([':', '}']).next() == Some("HasExtendedXMP"))
.map(|(_, values)| values.first().map_or("", |value| value.trim()))
}
}
impl XmpData {
@@ -143,15 +235,75 @@ impl XmpValue {
}
}
/// Whether a structured value holds any non-blank text. A tree larger than the parser's node
/// budget counts as text-bearing (the conservative answer for a privacy check): parsed trees are
/// capped at MAX_NODES, so only a caller-built `XmpData` can reach it, and the walk stays bounded.
fn value_has_text(value: &XmpValue) -> bool {
let mut pending = Vec::new();
if pending.try_reserve(1).is_err() {
return true;
}
pending.push(value);
let mut visited = 0usize;
while let Some(value) = pending.pop() {
visited = visited.saturating_add(1);
if visited > MAX_NODES {
return true;
}
let children = match value {
XmpValue::Text(text) if !text.trim().is_empty() => return true,
XmpValue::Text(_) => continue,
XmpValue::Array(items) => items.len(),
XmpValue::Struct(fields) => fields.len(),
};
if pending.len().saturating_add(children) > MAX_NODES || pending.try_reserve(children).is_err() {
return true;
}
match value {
XmpValue::Array(items) => pending.extend(items),
XmpValue::Struct(fields) => pending.extend(fields.values()),
XmpValue::Text(_) => {}
}
}
false
}
/// Insert a property into a struct or description map, keeping an earlier text-bearing value when
/// the same name repeats with a blank one: an IPTC location struct repeated with an empty
/// `rdf:li` must not hide the place an export's Remove Location has to refuse. `has_text`
/// remembers each key's answer so every value is walked at most once.
fn insert_keeping_text(map: &mut BTreeMap<String, XmpValue>, has_text: &mut BTreeMap<String, bool>, key: String, value: XmpValue) {
match map.entry(key) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert(value);
}
std::collections::btree_map::Entry::Occupied(mut slot) => {
let existing_has_text = match has_text.get(slot.key()) {
Some(known) => *known,
None => value_has_text(slot.get()),
};
if existing_has_text {
has_text.insert(slot.key().clone(), true);
return;
}
let new_has_text = value_has_text(&value);
has_text.insert(slot.key().clone(), new_has_text);
slot.insert(value);
}
}
}
/// A description or struct node as a map of its fields (attributes and child elements).
fn node_struct(n: &Node) -> XmpValue {
let mut m = BTreeMap::new();
let mut has_text = BTreeMap::new();
for (k, v) in &n.attrs {
if !is_rdf_meta_attr(k) {
m.insert(k.clone(), XmpValue::Text(v.clone()));
insert_keeping_text(&mut m, &mut has_text, k.clone(), XmpValue::Text(v.clone()));
}
}
for c in &n.children {
m.insert(c.name.clone(), node_value(c));
insert_keeping_text(&mut m, &mut has_text, c.name.clone(), node_value(c));
}
XmpValue::Struct(m)
}
@@ -193,6 +345,18 @@ pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024;
/// retains its prefix once per property: node limits alone cannot bound that duplication.
const MAX_KEY_BYTES: usize = 1 << 20;
/// Most namespace declarations in scope at once. Prefix lookup scans them, so declarations times the names of a
/// start tag is the work; real packets declare a few dozen.
const MAX_NS_DECLS: usize = 512;
/// Refuses a scope stack holding more than [`MAX_NS_DECLS`] declarations (one count per start tag over a stack at most [`MAX_DEPTH`] deep).
pub(crate) fn check_scopes(scopes: &[Vec<(String, String)>]) -> Result<(), XmpError> {
if scopes.iter().map(Vec::len).sum::<usize>() > MAX_NS_DECLS {
return Err(XmpError::TooComplex);
}
Ok(())
}
/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque.
pub(crate) fn canonical_name(
qname: &str,
@@ -265,6 +429,12 @@ fn xml_safe(s: &str) -> String {
s.chars().filter(|&c| is_xml_char(c)).collect()
}
/// Escaped text with each carriage return as `&#13;`: XML end-of-line handling turns a literal one
/// into a line feed on read.
fn keep_cr(escaped: std::borrow::Cow<'_, str>) -> String {
escaped.replace('\r', "&#13;")
}
/// Index of the `;` that ends a reference starting at `rest` (its `&`). The scan stops at the first
/// byte that cannot appear in a name or a numeric reference, so zero-padded references such as
/// `&#000000097;` are found and each run of name bytes is read once, not once per `&`.
@@ -323,6 +493,7 @@ fn parse_tree(s: &str, extra: &[XmpNamespace]) -> Result<Node, XmpError> {
}
}
scopes.push(scope);
check_scopes(&scopes)?;
let name = canonical_name(&String::from_utf8_lossy(e.name().as_ref()), &scopes, extra, &mut opaque_budget)?;
let attrs = raw_attrs.into_iter().map(|(k, v)| Ok((canonical_name(&k, &scopes, extra, &mut opaque_budget)?, v))).collect::<Result<
_,
@@ -386,37 +557,43 @@ fn is_rdf_meta_attr(k: &str) -> bool {
matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns")
}
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize, truncated: &mut bool) {
for (k, v) in &d.attrs {
if is_rdf_meta_attr(k) {
continue;
}
// the budget is checked before the path is built, not after
if is_rdf_meta_attr(k) || *budget < prefix.len().saturating_add(k.len()) {
if *budget < prefix.len().saturating_add(k.len()) {
*truncated = true;
continue;
}
let key = format!("{prefix}{k}");
if take_key_bytes(&key, budget) {
if take_key_bytes(&key, budget, truncated) {
props.entry(key).or_default().push(v.clone());
}
}
for c in &d.children {
collect_property(c, prefix, props, budget);
collect_property(c, prefix, props, budget, truncated);
}
}
/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the
/// property is then left out, like other elements a limit has cut).
fn take_key_bytes(key: &str, budget: &mut usize) -> bool {
/// property is then left out, like other elements a limit has cut) and the map is marked partial.
fn take_key_bytes(key: &str, budget: &mut usize, dropped: &mut bool) -> bool {
let n = key.len();
if *budget < n {
*dropped = true;
return false;
}
*budget -= n;
true
}
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize, truncated: &mut bool) {
// a path that can't fit the budget isn't built (a long ancestor path shared by many children
// would otherwise be copied for each of them after the budget is spent)
if *budget < prefix.len().saturating_add(p.name.len()) {
*truncated = true;
return;
}
let key = format!("{prefix}{}", p.name);
@@ -432,7 +609,7 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<Str
if arr.name == "rdf:Alt" {
items.sort_by_key(|(default, _)| !*default);
}
if take_key_bytes(&key, budget) {
if take_key_bytes(&key, budget, truncated) {
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
}
return;
@@ -441,30 +618,30 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<Str
// a struct's path is charged like a key before its fields copy it: that work too is bounded
// by the budget, kept fields or not
let is_struct = p.attr("rdf:parseType") == Some("Resource") || p.children.iter().any(|c| c.name == "rdf:Description");
if is_struct && !take_key_bytes(&key, budget) {
if is_struct && !take_key_bytes(&key, budget, truncated) {
return;
}
if p.attr("rdf:parseType") == Some("Resource") {
// a Resource struct carries fields as attributes as well as child elements, like a nested Description
collect_description(p, &struct_prefix, props, budget);
collect_description(p, &struct_prefix, props, budget, truncated);
return;
}
if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") {
collect_description(d, &struct_prefix, props, budget);
collect_description(d, &struct_prefix, props, budget, truncated);
return;
}
let field_attrs: Vec<&(String, String)> = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect();
if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() {
for (k, v) in field_attrs {
let full = format!("{struct_prefix}{k}");
if take_key_bytes(&full, budget) {
if take_key_bytes(&full, budget, truncated) {
props.entry(full).or_default().push(v.clone());
}
}
return;
}
let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string());
if take_key_bytes(&key, budget) {
if take_key_bytes(&key, budget, truncated) {
props.entry(key).or_default().push(v);
}
}
@@ -479,21 +656,95 @@ fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
}
}
/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` (k = N/S/E/W) → signed degrees.
fn parse_gps_coord(s: &str) -> Option<f64> {
/// Whether a canonical property name is an Exif GPS property.
fn is_gps_name(name: &str) -> bool {
name.starts_with("exif:GPS") || name.starts_with("exifEX:GPS")
}
/// Whether the tree names an Exif GPS property anywhere or holds a place property with non-blank
/// text anywhere. Each node is visited at most twice (a place's subtree is scanned once and not
/// walked again), and the parser caps the tree at `MAX_NODES`.
fn tree_names_location(root: &Node) -> bool {
let mut pending = vec![root];
while let Some(node) = pending.pop() {
if is_gps_name(&node.name) {
return true;
}
for (name, value) in &node.attrs {
if is_gps_name(name) || (LOCATION_PROPERTIES.contains(&name.as_str()) && !value.trim().is_empty()) {
return true;
}
}
if LOCATION_PROPERTIES.contains(&node.name.as_str()) {
if subtree_has_text(node) {
return true;
}
// A blank place may still enclose a GPS property.
if subtree_names_gps(node) {
return true;
}
continue;
}
pending.extend(&node.children);
}
false
}
/// Whether a node or any descendant holds non-blank text: element text, an `rdf:resource`, or a
/// field attribute (RDF syntax attributes such as `rdf:parseType` and `xml:lang` don't count).
fn subtree_has_text(root: &Node) -> bool {
let mut pending = vec![root];
while let Some(node) = pending.pop() {
if !node.text.trim().is_empty() {
return true;
}
if node.attrs.iter().any(|(name, value)| (name == "rdf:resource" || !is_rdf_meta_attr(name)) && !value.trim().is_empty()) {
return true;
}
pending.extend(&node.children);
}
false
}
/// Whether a node or any descendant is an Exif GPS property, as an element or an attribute.
fn subtree_names_gps(root: &Node) -> bool {
let mut pending = vec![root];
while let Some(node) = pending.pop() {
if is_gps_name(&node.name) || node.attrs.iter().any(|(name, _)| is_gps_name(name)) {
return true;
}
pending.extend(&node.children);
}
false
}
/// An `exif:GPSLatitude` / `exif:GPSLongitude` pair → (latitude, longitude) in signed degrees; `None`
/// unless both parse and lie on the globe (|latitude| ≤ 90, |longitude| ≤ 180, as the Exif reader checks).
pub fn parse_gps_position(lat: &str, lon: &str) -> Option<(f64, f64)> {
let (lat, lon) = (parse_gps_coord(lat, ['N', 'S'])?, parse_gps_coord(lon, ['E', 'W'])?);
((-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon)).then_some((lat, lon))
}
/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` → signed degrees, k being `[positive, negative]` of
/// its axis (N/S or E/W, either case). The sign comes only from the letter: a negative part
/// (`-10,30N`) is malformed.
fn parse_gps_coord(s: &str, [pos, neg]: [char; 2]) -> Option<f64> {
let s = s.trim();
let dir = s.chars().last()?;
let body = &s[..s.len() - dir.len_utf8()];
let parts: Vec<f64> = body.split(',').map(|p| p.trim().parse::<f64>().ok()).collect::<Option<_>>()?;
if parts.iter().any(|p| p.is_sign_negative()) {
return None;
}
let v = match parts.as_slice() {
[d] => *d,
[d, m] => d + m / 60.0,
[d, m, sec] => d + m / 60.0 + sec / 3600.0,
[d, m] if (0.0..60.0).contains(m) => d + m / 60.0,
[d, m, sec] if (0.0..60.0).contains(m) && (0.0..60.0).contains(sec) => d + m / 60.0 + sec / 3600.0,
_ => return None,
};
let v = match dir.to_ascii_uppercase() {
'N' | 'E' => v,
'S' | 'W' => -v,
c if c == pos => v,
c if c == neg => -v,
_ => return None,
};
v.is_finite().then_some(v)
@@ -581,6 +832,183 @@ fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String {
format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos })
}
// Pixel dimensions are integers. Keep decimal coefficients as digits so wide rational components
// remain exact and fractions that `f64` rounds to an integer can still be rejected.
struct ExactDecimal {
negative: bool,
coefficient: String,
exponent: i64,
}
struct ExactRatio {
negative: bool,
numerator: String,
denominator: String,
exponent: i64,
}
fn exact_decimal(value: &str) -> Option<ExactDecimal> {
let value = value.trim();
let (negative, value) = if let Some(value) = value.strip_prefix('-') {
(true, value)
} else if let Some(value) = value.strip_prefix('+') {
(false, value)
} else {
(false, value)
};
let (mantissa, exponent) = if let Some((mantissa, exponent)) = value.split_once('e').or_else(|| value.split_once('E')) {
if exponent.contains('e') || exponent.contains('E') {
return None;
}
(mantissa, exponent.parse::<i64>().ok()?)
} else {
(value, 0)
};
let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
if fraction.contains('.') {
return None;
}
let digits = whole.bytes().chain(fraction.bytes());
if !digits.clone().all(|digit| digit.is_ascii_digit()) {
return None;
}
let count = whole.len().checked_add(fraction.len())?;
if count == 0 {
return None;
}
let leading = digits.clone().take_while(|digit| *digit == b'0').count();
if leading == count {
return Some(ExactDecimal { negative, coefficient: "0".to_owned(), exponent: 0 });
}
let trailing = digits.clone().rev().take_while(|digit| *digit == b'0').count();
let significant = count.checked_sub(leading)?.checked_sub(trailing)?;
let mut coefficient = String::with_capacity(significant);
for digit in digits.skip(leading).take(significant) {
coefficient.push(char::from(digit));
}
let exponent = exponent.checked_sub(i64::try_from(fraction.len()).ok()?)?.checked_add(i64::try_from(trailing).ok()?)?;
Some(ExactDecimal { negative, coefficient, exponent })
}
fn exact_ratio(numerator: ExactDecimal, denominator: ExactDecimal) -> Option<ExactRatio> {
if denominator.coefficient == "0" {
return None;
}
Some(ExactRatio {
negative: numerator.negative ^ denominator.negative,
numerator: numerator.coefficient,
denominator: denominator.coefficient,
exponent: numerator.exponent.checked_sub(denominator.exponent)?,
})
}
fn multiply_decimal_by_u32(value: &str, multiplier: u32) -> String {
if multiplier == 0 {
return "0".to_owned();
}
let mut digits = Vec::with_capacity(value.len().saturating_add(10));
let mut carry = 0u64;
for digit in value.bytes().rev() {
let product = u64::from(digit - b'0') * u64::from(multiplier) + carry;
digits.push(b'0' + (product % 10) as u8);
carry = product / 10;
}
while carry != 0 {
digits.push(b'0' + (carry % 10) as u8);
carry /= 10;
}
digits.reverse();
digits.into_iter().map(char::from).collect()
}
fn decimal_significand(value: &str) -> Option<f64> {
let mut prefix = 0u64;
let mut digits = 0u32;
for digit in value.bytes().take(17) {
prefix = prefix.checked_mul(10)?.checked_add(u64::from(digit.checked_sub(b'0')?))?;
digits = digits.checked_add(1)?;
}
let exponent = i32::try_from(digits.checked_sub(1)?).ok()?;
Some(prefix as f64 / 10.0_f64.powi(exponent))
}
fn compare_scaled_decimal(left: &str, left_exponent: i64, right: &str, right_exponent: i64) -> Option<std::cmp::Ordering> {
if left == "0" {
return Some(if right == "0" { std::cmp::Ordering::Equal } else { std::cmp::Ordering::Less });
}
if right == "0" {
return Some(std::cmp::Ordering::Greater);
}
let left_magnitude = i128::try_from(left.len()).ok()?.checked_add(i128::from(left_exponent))?;
let right_magnitude = i128::try_from(right.len()).ok()?.checked_add(i128::from(right_exponent))?;
match left_magnitude.cmp(&right_magnitude) {
std::cmp::Ordering::Equal => {}
ordering => return Some(ordering),
}
let length = left.len().max(right.len());
for (left_digit, right_digit) in left.bytes().chain(std::iter::repeat(b'0')).zip(right.bytes().chain(std::iter::repeat(b'0'))).take(length) {
match left_digit.cmp(&right_digit) {
std::cmp::Ordering::Equal => {}
ordering => return Some(ordering),
}
}
Some(std::cmp::Ordering::Equal)
}
fn exact_ratio_to_u32(value: ExactRatio) -> Option<u32> {
if value.negative || value.denominator == "0" {
return None;
}
if value.numerator == "0" {
return Some(0);
}
if value.exponent < -128 || value.exponent > 128 {
return None;
}
let numerator_length = i128::try_from(value.numerator.len()).ok()?;
let denominator_length = i128::try_from(value.denominator.len()).ok()?;
let decimal_scale = numerator_length.checked_add(i128::from(value.exponent))?.checked_sub(denominator_length)?;
if !(0..=10).contains(&decimal_scale) {
return None;
}
// The prefix only estimates a candidate. Acceptance compares the full exact coefficients below.
let approximate =
decimal_significand(&value.numerator)? / decimal_significand(&value.denominator)? * 10.0_f64.powi(i32::try_from(decimal_scale).ok()?);
if !approximate.is_finite() {
return None;
}
let integer = approximate.round().clamp(0.0, f64::from(u32::MAX)) as u32;
let product = multiply_decimal_by_u32(&value.denominator, integer);
let ordering = if product == "0" {
compare_scaled_decimal(&value.numerator, value.exponent, "0", 0)?
} else {
let trailing_zeroes = product.bytes().rev().take_while(|digit| *digit == b'0').count();
let product_exponent = i64::try_from(trailing_zeroes).ok()?;
compare_scaled_decimal(&value.numerator, value.exponent, product.trim_end_matches('0'), product_exponent)?
};
(ordering == std::cmp::Ordering::Equal).then_some(integer)
}
fn exact_u32_from_text(value: &str) -> Option<u32> {
let value = value.trim();
let ratio = if let Some((numerator, denominator)) = value.split_once('/') {
exact_ratio(exact_decimal(numerator)?, exact_decimal(denominator)?)?
} else {
let value = exact_decimal(value)?;
ExactRatio { negative: value.negative, numerator: value.coefficient, denominator: "1".to_owned(), exponent: value.exponent }
};
exact_ratio_to_u32(ratio)
}
/// A positive whole-number ISO, read exactly (a fraction or an out-of-range value is not one).
fn iso_from_text(value: &str) -> Option<u32> {
exact_u32_from_text(value).filter(|&iso| iso > 0)
}
fn dimension_candidate(first: impl Fn(&str) -> Option<String>, key: &str) -> Option<u32> {
first(key).and_then(|value| exact_u32_from_text(&value))
}
/// Parse an XMP packet (with or without the `<?xpacket?>` wrapper / `x:xmpmeta` element).
pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
parse_xmp_with(s, &[])
@@ -590,18 +1018,36 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result<XmpData, XmpError> {
let s = s.trim_start_matches('\u{feff}');
let tree = parse_tree(s, extra)?;
let names_location = tree_names_location(&tree);
let mut descs = Vec::new();
find_descriptions(&tree, &mut descs);
let mut props = BTreeMap::new();
let mut values = BTreeMap::new();
let mut properties_truncated = false;
let mut value_has_text_by_key = BTreeMap::new();
// One budget for the whole document: several descriptions cannot each spend a fresh one.
let mut key_budget = MAX_KEY_BYTES;
for d in descs {
collect_description(d, "", &mut props, &mut key_budget);
collect_description(d, "", &mut props, &mut key_budget, &mut properties_truncated);
if let XmpValue::Struct(m) = node_struct(d) {
values.extend(m);
// Later descriptions can repeat a property too; the same rule keeps an earlier place.
for (key, value) in m {
insert_keeping_text(&mut values, &mut value_has_text_by_key, key, value);
}
}
}
let m = project(&props, &values);
let toolkit = tree
.children
.iter()
.find(|n| n.name == "x:xmpmeta" || n.name == "x:xapmeta")
.and_then(|n| n.attr("x:xmptk").or_else(|| n.attr("x:xaptk")))
.map(str::to_string);
Ok(XmpData { metadata: m, properties: props, properties_truncated, values, toolkit, names_location })
}
/// The typed [`Metadata`] the flattened `props` and structured `values` of a packet state.
fn project(props: &BTreeMap<String, Vec<String>>, values: &BTreeMap<String, XmpValue>) -> Metadata {
let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let num = |k: &str| first(k).and_then(|s| parse_number(&s));
let mut m = Metadata {
@@ -620,9 +1066,9 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result<XmpData, XmpErr
exposure_program: num("exif:ExposureProgram").map(|v| v as u16),
metering_mode: num("exif:MeteringMode").map(|v| v as u16),
white_balance: num("exif:WhiteBalance").map(|v| v as u16),
orientation: num("tiff:Orientation").map(|v| v as u16).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif),
width: num("tiff:ImageWidth").filter(|v| *v >= 0.0).or_else(|| num("exif:PixelXDimension").filter(|v| *v >= 0.0)).map(|v| v as u32),
height: num("tiff:ImageLength").filter(|v| *v >= 0.0).or_else(|| num("exif:PixelYDimension").filter(|v| *v >= 0.0)).map(|v| v as u32),
orientation: num("tiff:Orientation").filter(|v| (1.0..=8.0).contains(v) && v.fract() == 0.0).map(|v| Orientation::from_exif(v as u16)),
width: dimension_candidate(first, "tiff:ImageWidth").or_else(|| dimension_candidate(first, "exif:PixelXDimension")),
height: dimension_candidate(first, "tiff:ImageLength").or_else(|| dimension_candidate(first, "exif:PixelYDimension")),
title: first("dc:title"),
caption: first("dc:description"),
alt_text: first("Iptc4xmpCore:AltTextAccessibility"),
@@ -643,7 +1089,8 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result<XmpData, XmpErr
label: first("xmp:Label"),
..Default::default()
};
m.iso = [num("exif:ISOSpeedRatings"), num("exifEX:PhotographicSensitivity")].into_iter().flatten().find(|v| *v > 0.0).map(|v| v as u32);
let iso = |key: &str| first(key).and_then(|value| iso_from_text(&value));
m.iso = [iso("exif:ISOSpeedRatings"), iso("exifEX:PhotographicSensitivity")].into_iter().flatten().next();
if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) {
let v: Vec<f64> = spec.iter().filter_map(|s| parse_number(s)).collect();
if v.len() == 4 {
@@ -668,22 +1115,72 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result<XmpData, XmpErr
| (b("exif:Flash/exif:RedEyeMode") as u16) << 6;
m.flash = Some(Flash { fired, raw });
}
if let (Some(lat), Some(lon)) =
(first("exif:GPSLatitude").and_then(|s| parse_gps_coord(&s)), first("exif:GPSLongitude").and_then(|s| parse_gps_coord(&s)))
&& (-90.0..=90.0).contains(&lat)
&& (-180.0..=180.0).contains(&lon)
{
if let Some((lat, lon)) = first("exif:GPSLatitude").zip(first("exif:GPSLongitude")).and_then(|(la, lo)| parse_gps_position(&la, &lo)) {
let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a });
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
}
m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default();
let toolkit = tree
.children
.iter()
.find(|n| n.name == "x:xmpmeta" || n.name == "x:xapmeta")
.and_then(|n| n.attr("x:xmptk").or_else(|| n.attr("x:xaptk")))
.map(str::to_string);
Ok(XmpData { metadata: m, properties: props, values, toolkit })
m
}
/// A standard JPEG XMP packet and its Extended XMP continuation, read as one (Adobe XMP
/// Specification Part 3, 1.1.3.1): properties of the standard packet win, arrays and structs
/// stated in both are combined, and the `xmpNote:HasExtendedXMP` link itself is left out.
pub fn parse_xmp_pair(standard: &str, extended: &str, extra: &[XmpNamespace]) -> Result<XmpData, XmpError> {
let ext = parse_xmp_with(extended, extra)?;
let std = parse_xmp_with(standard, extra)?;
let mut properties = ext.properties;
properties.extend(std.properties);
let mut values = ext.values;
for (key, preferred) in std.values {
match values.get_mut(&key) {
Some(fallback) => *fallback = merge_xmp_value(&preferred, fallback),
None => {
values.insert(key, preferred);
}
}
}
properties.remove("xmpNote:HasExtendedXMP");
values.remove("xmpNote:HasExtendedXMP");
let metadata = project(&properties, &values);
Ok(XmpData {
metadata,
properties,
properties_truncated: std.properties_truncated || ext.properties_truncated,
values,
toolkit: std.toolkit,
names_location: std.names_location || ext.names_location,
})
}
/// `preferred` with what `fallback` adds: array items appended, struct fields merged.
fn merge_xmp_value(preferred: &XmpValue, fallback: &XmpValue) -> XmpValue {
match (preferred, fallback) {
(XmpValue::Array(preferred), XmpValue::Array(fallback)) => XmpValue::Array(preferred.iter().chain(fallback).cloned().collect()),
(XmpValue::Struct(preferred), XmpValue::Struct(fallback)) => {
let mut merged = fallback.clone();
for (key, value) in preferred {
let combined = merged.get(key).map_or_else(|| value.clone(), |fallback| merge_xmp_value(value, fallback));
merged.insert(key.clone(), combined);
}
XmpValue::Struct(merged)
}
_ => preferred.clone(),
}
}
/// The GUID a standard packet's `xmpNote:HasExtendedXMP` names, upper-cased; `Ok(None)` without
/// one. Only the XMP Note namespace counts (a foreign `HasExtendedXMP` is not a link), and the
/// property must occur once, as 32 hexadecimal digits.
pub fn extended_xmp_reference(data: &XmpData) -> Result<Option<String>, String> {
let Some(values) = data.properties.get("xmpNote:HasExtendedXMP") else { return Ok(None) };
let [value] = values.as_slice() else {
return Err("XMP HasExtendedXMP reference must occur exactly once".into());
};
if value.len() != 32 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("XMP HasExtendedXMP reference must be 32 hexadecimal characters".into());
}
Ok(Some(value.to_ascii_uppercase()))
}
fn frac(v: f64) -> String {
@@ -759,20 +1256,24 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String {
x.push_str(&format!("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"{}\">\n", escape(xml_safe(with.toolkit))));
x.push_str(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n");
x.push_str(" <rdf:Description rdf:about=\"\"");
// the caller's namespace takes the place of `crs`, which is read only
// the caller's namespace takes the place of `crs`, which is read only, as is `xmpNote`
let own = with.namespace.map(|n| (n.prefix, n.uri));
for (p, u) in NAMESPACES.iter().skip(2).filter_map(|&ns| if ns.0 == "crs" { own } else { Some(ns) }) {
for (p, u) in NAMESPACES.iter().skip(2).filter_map(|&ns| match ns.0 {
"crs" => own,
"xmpNote" => None,
_ => Some(ns),
}) {
x.push_str(&format!("\n xmlns:{p}=\"{}\"", escape(xml_safe(u))));
}
x.push_str(">\n");
for (k, v) in &simple {
x.push_str(&format!(" <{k}>{}</{k}>\n", escape(xml_safe(v))));
x.push_str(&format!(" <{k}>{}</{k}>\n", keep_cr(escape(xml_safe(v)))));
}
for (k, v) in with.properties.iter().filter(|_| own.is_some()) {
let p = own.map_or("", |n| n.0);
if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
// element content: quotes needn't be escaped, which keeps JSON readable
x.push_str(&format!(" <{p}:{k}>{}</{p}:{k}>\n", partial_escape(xml_safe(v))));
x.push_str(&format!(" <{p}:{k}>{}</{p}:{k}>\n", keep_cr(partial_escape(xml_safe(v)))));
}
}
let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| {
@@ -782,9 +1283,9 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String {
x.push_str(&format!(" <{k}>\n <rdf:{kind}>\n"));
for it in items {
if lang {
x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(xml_safe(it))));
x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", keep_cr(escape(xml_safe(it)))));
} else {
x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(xml_safe(it))));
x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", keep_cr(escape(xml_safe(it)))));
}
}
x.push_str(&format!(" </rdf:{kind}>\n </{k}>\n"));
@@ -799,7 +1300,7 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String {
array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false);
array(&mut x, "dc:subject", "Bag", &meta.keywords, false);
array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false);
array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.map(|v| vec![v.to_string()]).unwrap_or_default(), false);
array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.filter(|value| *value > 0).map(|v| vec![v.to_string()]).unwrap_or_default(), false);
if let Some(s) = meta.lens_spec {
array(&mut x, "exifEX:LensSpecification", "Seq", &s.iter().map(|&v| frac(v)).collect::<Vec<_>>(), false);
}
+466 -7
View File
@@ -194,6 +194,109 @@ fn non_positive_iso_is_ignored() {
assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200));
}
fn iso_packet(attrs: &str, element: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:exifEX="http://cipa.jp/exif/1.0/" {attrs}>{element}</rdf:Description>
</rdf:RDF></x:xmpmeta>"#
)
}
#[test]
fn xmp_iso_rejects_fractional_values() {
let cases = [
(iso_packet(r#"exif:ISOSpeedRatings="0.5""#, ""), "exif:ISOSpeedRatings", "0.5"),
(iso_packet(r#"exif:ISOSpeedRatings="1.0000000000000001""#, ""), "exif:ISOSpeedRatings", "1.0000000000000001"),
(iso_packet(r#"exif:ISOSpeedRatings="1.0000000000000001/1""#, ""), "exif:ISOSpeedRatings", "1.0000000000000001/1"),
(iso_packet("", "<exif:ISOSpeedRatings><rdf:Seq><rdf:li>160.9</rdf:li></rdf:Seq></exif:ISOSpeedRatings>"), "exif:ISOSpeedRatings", "160.9"),
(iso_packet(r#"exifEX:PhotographicSensitivity="160.9""#, ""), "exifEX:PhotographicSensitivity", "160.9"),
(iso_packet("", "<exifEX:PhotographicSensitivity>0.5</exifEX:PhotographicSensitivity>"), "exifEX:PhotographicSensitivity", "0.5"),
(
iso_packet("", "<exifEX:PhotographicSensitivity><rdf:Seq><rdf:li>160.9</rdf:li></rdf:Seq></exifEX:PhotographicSensitivity>"),
"exifEX:PhotographicSensitivity",
"160.9",
),
];
for (packet, key, raw) in cases {
let d = parse_xmp(&packet).unwrap();
assert_eq!(d.metadata.iso, None, "{packet}");
assert_eq!(
d.properties.get(key).map(|values| values.iter().map(String::as_str).collect::<Vec<_>>()),
Some(vec![raw]),
"raw property changed: {packet}"
);
assert_eq!(d.metadata.make, None);
}
}
#[test]
fn xmp_iso_rejects_out_of_range_values() {
for value in ["4294967296", "0", "-1", "NaN", "inf", "bad"] {
let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), "");
let d = parse_xmp(&packet).unwrap();
assert_eq!(d.metadata.iso, None, "{value}");
assert_eq!(d.properties.get("exif:ISOSpeedRatings").map(|v| v.first().map(String::as_str)), Some(Some(value)));
}
let packet = iso_packet("", "<exifEX:PhotographicSensitivity>4294967296</exifEX:PhotographicSensitivity>");
assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, None);
}
#[test]
fn xmp_iso_invalid_primary_allows_fallback() {
for primary in ["0.5", "1.0000000000000001", "160.9", "4294967296", "0", "-1", "bad", "NaN"] {
let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{primary}" exifEX:PhotographicSensitivity="200""#), "");
assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(200), "{primary}");
}
}
#[test]
fn xmp_iso_preserves_valid_values() {
for (value, expected) in [
("200", 200),
("200.0", 200),
("2e2", 200),
("400/2", 200),
("1.5/0.5", 3),
("0.3/0.1", 3),
("1.0000000000000001/1.0000000000000001", 1),
("1e39/1e39", 1),
("1e39/5e38", 2),
("1e39/2e38", 5),
("4294967295", u32::MAX),
] {
let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), "");
assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(expected), "{value}");
}
assert_eq!(parse_xmp(&iso_packet("", "<exifEX:PhotographicSensitivity>640</exifEX:PhotographicSensitivity>")).unwrap().metadata.iso, Some(640));
let packet = iso_packet(r#"exif:ISOSpeedRatings="100" exifEX:PhotographicSensitivity="200""#, "");
assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(100));
}
#[test]
fn xmp_iso_accepts_equal_coefficients_above_u128() {
let value = "340282366920938463463374607431768211456/340282366920938463463374607431768211456";
assert_eq!(parse_xmp(&iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), "")).unwrap().metadata.iso, Some(1));
}
#[test]
fn xmp_iso_rejected_value_is_not_written() {
let packet = iso_packet(r#"exif:ISOSpeedRatings="0.5""#, r#"<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">kept</dc:title>"#);
let parsed = parse_xmp(&packet).unwrap();
assert_eq!(parsed.metadata.iso, None);
assert_eq!(parsed.metadata.title.as_deref(), Some("kept"));
let written = write_xmp(&parsed.metadata);
assert!(!written.contains("ISOSpeedRatings"), "{written}");
}
#[test]
fn xmp_iso_writer_omits_zero() {
let written = write_xmp(&Metadata { iso: Some(0), ..Default::default() });
assert!(!written.contains("ISOSpeedRatings"), "{written}");
let written = write_xmp(&Metadata { iso: Some(200), ..Default::default() });
assert!(written.contains("<rdf:li>200</rdf:li>"), "{written}");
}
#[test]
fn negative_xmp_pixel_width_is_ignored() {
let read = |attrs: &str| {
@@ -210,6 +313,119 @@ fn negative_xmp_pixel_width_is_ignored() {
assert_eq!(read(r#"exif:PixelXDimension="-1""#), None);
}
fn xmp_dimensions(body: &str) -> XmpData {
let (attributes, elements) = body.split_once('|').unwrap_or((body, ""));
let packet = format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/" {attributes}>{elements}</rdf:Description>
</rdf:RDF>"#
);
parse_xmp(&packet).expect("procedural XMP dimension fixture parses")
}
#[test]
fn xmp_dimensions_reject_fractional_values() {
for (field, value) in [
("tiff:ImageWidth", "640.9"),
("exif:PixelXDimension", "640.9"),
("tiff:ImageLength", "480.9"),
("exif:PixelYDimension", "480.9"),
("tiff:ImageWidth", "1.0000000000000001"),
] {
let data = xmp_dimensions(&format!(r#"{field}="{value}""#));
assert_eq!(data.metadata.width, None, "{field}={value}");
assert_eq!(data.metadata.height, None, "{field}={value}");
assert_eq!(data.properties.get(field).map(Vec::as_slice), Some([value.to_owned()].as_slice()), "raw {field}={value}");
}
}
#[test]
fn xmp_dimensions_reject_out_of_range_values() {
for (field, value) in [
("tiff:ImageWidth", "4294967296"),
("exif:PixelXDimension", "4294967296"),
("tiff:ImageLength", "4294967296"),
("exif:PixelYDimension", "4294967296"),
("tiff:ImageWidth", "-1"),
("tiff:ImageWidth", "-0"),
("tiff:ImageWidth", "0/0"),
("exif:PixelXDimension", "-1"),
("tiff:ImageLength", "-1"),
("exif:PixelYDimension", "-1"),
("tiff:ImageWidth", "1e100000"),
("tiff:ImageWidth", "NaN"),
("tiff:ImageWidth", "Infinity"),
("tiff:ImageWidth", "not-a-number"),
] {
let data = xmp_dimensions(&format!(r#"{field}="{value}""#));
assert_eq!(data.metadata.width, None, "{field}={value}");
assert_eq!(data.metadata.height, None, "{field}={value}");
assert_eq!(data.properties.get(field).map(Vec::as_slice), Some([value.to_owned()].as_slice()), "raw {field}={value}");
}
}
#[test]
fn xmp_dimensions_invalid_primary_allows_fallback() {
let width = xmp_dimensions(r#"tiff:ImageWidth="0.5" exif:PixelXDimension="640""#);
assert_eq!(width.metadata.width, Some(640));
let height = xmp_dimensions(r#"tiff:ImageLength="480.9" exif:PixelYDimension="480""#);
assert_eq!(height.metadata.height, Some(480));
}
#[test]
fn xmp_dimensions_preserve_valid_values() {
for (field, value, expected) in [
("tiff:ImageWidth", "0", 0),
("tiff:ImageWidth", "0001", 1),
("tiff:ImageWidth", "0/1e-129", 0),
("tiff:ImageWidth", "1e-400/1e-400", 1),
("tiff:ImageWidth", "1e400/1e400", 1),
("tiff:ImageWidth", "4294967295", u32::MAX),
("exif:PixelXDimension", "640.0", 640),
("tiff:ImageLength", "960/2", 480),
("exif:PixelYDimension", "48e1", 480),
("tiff:ImageWidth", "1e39/1e39", 1),
] {
let data = xmp_dimensions(&format!(r#"{field}="{value}""#));
let actual = if matches!(field, "tiff:ImageWidth" | "exif:PixelXDimension") { data.metadata.width } else { data.metadata.height };
assert_eq!(actual, Some(expected), "{field}={value}");
}
assert_eq!(xmp_dimensions(r#"tiff:ImageWidth="640" exif:PixelXDimension="800""#).metadata.width, Some(640));
assert_eq!(xmp_dimensions(r#"tiff:ImageLength="480" exif:PixelYDimension="800""#).metadata.height, Some(480));
let element = xmp_dimensions(r#"|<tiff:ImageWidth>640</tiff:ImageWidth><exif:PixelYDimension>480</exif:PixelYDimension>"#);
assert_eq!(element.metadata.width, Some(640));
assert_eq!(element.metadata.height, Some(480));
let sequence = xmp_dimensions(r#"|<tiff:ImageWidth><rdf:Seq><rdf:li>640</rdf:li></rdf:Seq></tiff:ImageWidth>"#);
assert_eq!(sequence.metadata.width, Some(640));
}
#[test]
fn xmp_dimensions_preserve_large_exact_ratio() {
for coefficient in ["999999999999999999999999999999999999999".to_owned(), "9".repeat(1024)] {
let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{coefficient}/{coefficient}""#));
assert_eq!(data.metadata.width, Some(1), "coefficient length {}", coefficient.len());
}
let denominator = "9".repeat(1024);
let numerator = format!("1{}8", "9".repeat(1023));
let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{numerator}/{denominator}""#));
assert_eq!(data.metadata.width, Some(2));
let numerator = format!("{}8", "9".repeat(1023));
let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{numerator}/{denominator}""#));
assert_eq!(data.metadata.width, None);
}
#[test]
fn xmp_dimensions_rejected_values_are_not_written() {
let data = xmp_dimensions(r#"tiff:ImageWidth="640.9" dc:title="Keep this""#);
assert_eq!(data.metadata.width, None);
assert_eq!(data.metadata.title.as_deref(), Some("Keep this"));
assert_eq!(data.properties.get("tiff:ImageWidth").map(Vec::as_slice), Some(["640.9".to_owned()].as_slice()));
let written = write_xmp(&data.metadata);
assert!(!written.contains("tiff:ImageWidth"));
assert_eq!(parse_xmp(&written).unwrap().metadata.width, None);
}
#[test]
fn malformed_inputs() {
assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err());
@@ -240,11 +456,20 @@ fn lc_extras_and_crs_prefix() {
#[test]
fn gps_coords() {
assert_eq!(parse_gps_coord("45,30N"), Some(45.5));
assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51));
assert_eq!(parse_gps_coord("45X"), None);
assert_eq!(parse_gps_coord(""), None);
assert_eq!(parse_gps_coord("é"), None);
let lat = ['N', 'S'];
assert_eq!(parse_gps_coord("45,30N", lat), Some(45.5));
assert_eq!(parse_gps_coord("45,30,36S", lat), Some(-45.51));
assert_eq!(parse_gps_coord("45X", lat), None);
assert_eq!(parse_gps_coord("", lat), None);
assert_eq!(parse_gps_coord("é", lat), None);
assert_eq!(parse_gps_coord("-10,30N", lat), None, "the sign is the letter's");
assert_eq!(parse_gps_coord("10,-30N", lat), None);
assert_eq!(parse_gps_coord("10,30E", lat), None, "E is a longitude's");
assert_eq!(parse_gps_position("10,30E", "20,15N"), None);
assert_eq!(parse_gps_position("10,30N", "20,15W"), Some((10.5, -20.25)));
assert_eq!(parse_gps_position("95,0N", "20,0E"), None, "off the globe");
assert_eq!(parse_gps_position("10,0N", "180,0.1E"), None);
assert_eq!(parse_gps_position("90,0S", "180,0W"), Some((-90.0, -180.0)));
assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S");
assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N");
assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W");
@@ -268,6 +493,49 @@ fn xmp_gps_out_of_range_rejected() {
assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None }));
}
#[test]
fn xmp_gps_component_bounds() {
let parse = |lat: &str, lon: &str| {
let x = format!(
r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:e="http://ns.adobe.com/exif/1.0/" e:GPSLatitude="{lat}" e:GPSLongitude="{lon}"/>"#
);
parse_xmp(&x).unwrap()
};
for (lat, lon) in [
("45,60N", "7,15E"),
("45,99N", "7,15E"),
("45,0,60N", "7,15E"),
("45,0,99N", "7,15E"),
("45,15N", "7,60E"),
("45,15N", "7,99E"),
("45,15N", "7,0,60E"),
("45,15N", "7,0,99E"),
] {
let xmp = parse(lat, lon);
assert_eq!(xmp.metadata.gps, None, "{lat}, {lon}");
assert_eq!(xmp.properties.get("exif:GPSLatitude").and_then(|v| v.first()).map(String::as_str), Some(lat));
assert_eq!(xmp.properties.get("exif:GPSLongitude").and_then(|v| v.first()).map(String::as_str), Some(lon));
}
for (lat, lon, expected) in [
("45,59.5N", "7,59.5E", (45.0 + 59.5 / 60.0, 7.0 + 59.5 / 60.0)),
("45,59,59S", "7,59,59W", (-(45.0 + 59.0 / 60.0 + 59.0 / 3600.0), -(7.0 + 59.0 / 60.0 + 59.0 / 3600.0))),
("0,0N", "0,0E", (0.0, 0.0)),
("90,0N", "180,0W", (90.0, -180.0)),
(" 45,30n ", " 7,15e ", (45.5, 7.25)),
] {
let xmp = parse(lat, lon);
let gps = xmp.metadata.gps.expect("valid XMP GPS components");
assert!((gps.latitude - expected.0).abs() < 1e-9, "{lat}: {}", gps.latitude);
assert!((gps.longitude - expected.1).abs() < 1e-9, "{lon}: {}", gps.longitude);
}
assert_eq!(parse("95,0N", "7,0E").metadata.gps, None, "combined latitude range remains checked");
assert_eq!(parse("45,0N", "200,0E").metadata.gps, None, "combined longitude range remains checked");
}
/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how
/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one
/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas
@@ -506,7 +774,10 @@ fn writer_drops_characters_xml_1_0_forbids() {
let illegal: Vec<char> =
x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect();
assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}");
assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}");
// a carriage return is written as a reference: a literal one would read back as a line feed (XML end-of-line handling)
assert!(x.contains(">abcde\tf\ng&#13;h<"), "allowed characters must survive: {x}");
let back = parse_xmp(&x).unwrap();
assert_eq!(back.metadata.caption.as_deref(), Some("abcde\tf\ng\rh"), "and read back unchanged");
}
#[test]
@@ -544,6 +815,7 @@ fn flattened_keys_stay_within_the_cumulative_budget() {
assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget");
assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent");
assert!(!d.properties.is_empty(), "properties parsed before the budget are kept");
assert!(d.properties_truncated, "omitted flattened keys are reported to callers");
}
#[test]
@@ -579,6 +851,15 @@ fn malformed_capture_time_without_fallback_is_not_projected() {
assert_eq!(parsed.properties["exif:DateTimeOriginal"], vec!["2026-01-01T".to_string()]);
}
#[test]
fn a_start_tag_with_thousands_of_namespace_declarations_is_refused() {
let decls: String = (0..2000).map(|i| format!(" xmlns:p{i}=\"urn:x{i}\"")).collect();
let xmp = format!(
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description{decls} p1:a=\"1\"/></rdf:RDF></x:xmpmeta>"
);
assert!(matches!(parse_xmp(&xmp), Err(XmpError::TooComplex)));
}
#[test]
fn many_ampersands_before_one_semicolon_are_read_once() {
// 200 000 `&` then a `;`: each `&` once searched the whole rest of the attribute for the `;`
@@ -617,7 +898,7 @@ fn the_callers_namespace_is_written_and_read_only_when_passed() {
let x = write_lc(&Metadata::default(), &[("flag", "pick")]);
assert!(x.contains("x:xmptk=\"Editor\""));
assert!(x.contains(
"xmlns:Iptc4xmpCore=\"http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/\"\n xmlns:lc=\"http://ns.example.com/lc/1.0/\"\n xmlns:mwg-rs="
"xmlns:Iptc4xmpCore=\"http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/\"\n xmlns:Iptc4xmpExt=\"http://iptc.org/std/Iptc4xmpExt/2008-02-29/\"\n xmlns:lc=\"http://ns.example.com/lc/1.0/\"\n xmlns:mwg-rs="
));
assert!(!x.contains("xmlns:crs"));
let renamed = x.replace("xmlns:lc=", "xmlns:zz=").replace("lc:flag", "zz:flag");
@@ -671,3 +952,181 @@ fn the_toolkit_is_read_and_written_back() {
let bare = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"/>"#;
assert_eq!(crate::parse_xmp(bare).unwrap().toolkit, None);
}
fn orientation_packet(value: &str) -> String {
format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:tiff="http://ns.adobe.com/tiff/1.0/" tiff:Orientation="{value}"/></rdf:RDF>"#
)
}
#[test]
fn xmp_orientation_rejects_fractional_values_and_keeps_raw_properties() {
for value in ["6.5", "6.9", "1.5", "8.9", "13/2"] {
let parsed = parse_xmp(&orientation_packet(value)).unwrap();
assert_eq!(parsed.metadata.orientation, None, "{value}");
assert_eq!(parsed.properties["tiff:Orientation"], vec![value.to_string()], "{value}");
assert!(!write_xmp(&parsed.metadata).contains("tiff:Orientation"), "rejected {value} must not become a written orientation");
}
}
#[test]
fn xmp_orientation_integral_forms_keep_their_meaning() {
for code in 1..=8 {
let parsed = parse_xmp(&orientation_packet(&code.to_string())).unwrap();
assert_eq!(parsed.metadata.orientation.map(|orientation| orientation.to_exif()), Some(code));
}
for value in ["6.0", "6/1", " 6 ", " 6.0 "] {
let parsed = parse_xmp(&orientation_packet(value)).unwrap();
assert_eq!(parsed.metadata.orientation.map(|orientation| orientation.to_exif()), Some(6), "{value}");
}
}
#[test]
fn xmp_orientation_rejects_existing_invalid_values() {
for value in ["0", "9", "-1", "nan", "inf"] {
let parsed = parse_xmp(&orientation_packet(value)).unwrap();
assert_eq!(parsed.metadata.orientation, None, "{value}");
assert_eq!(parsed.properties["tiff:Orientation"], vec![value.to_string()], "{value}");
}
}
/// References resolve, anything else (unknown, unterminated, or a `;` too far away to end one) stays as
/// written, and a value that is nothing but `&` is handled in one pass.
#[test]
fn attribute_references_resolve_and_hostile_ones_stay_verbatim() {
assert_eq!(unescape_attr("a&amp;b&#65;&#x42;&lt;&gt;&quot;&apos;"), "a&bAB<>\"'");
assert_eq!(unescape_attr("&bogus; & &#xZZ; tail&"), "&bogus; & &#xZZ; tail&");
let far = format!("&{};", "x".repeat(40));
assert_eq!(unescape_attr(&far), far);
let many = "&".repeat(1 << 20);
assert_eq!(unescape_attr(&many), many);
// multi-byte text around a reference is never cut inside a character
assert_eq!(unescape_attr("é&amp;日&é;"), "é&日&é;");
// leading zeros are valid in numeric references, however many there are
assert_eq!(unescape_attr("&#00000000000000000065;&#x000000000000000042;"), "AB");
let zeros = format!("&#{};", "0".repeat(1 << 20));
assert_eq!(unescape_attr(&zeros), zeros);
}
/// A small packet keeps every property and is not marked truncated.
#[test]
fn a_packet_within_the_budget_keeps_a_complete_inventory() {
let d = parse_xmp(r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="A"/></rdf:RDF>"#).unwrap();
assert!(!d.properties_truncated);
assert_eq!(d.properties.len(), 1);
}
/// A later description that repeats `LocationShown` with an empty item must not erase the city
/// an earlier description named: the packet still holds a place the export has to refuse.
#[test]
fn a_later_empty_location_keeps_an_earlier_city() {
let packet = concat!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:place="http://iptc.org/std/Iptc4xmpExt/2008-02-29/">"#,
r#"<rdf:Description><place:LocationShown><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
r#"<place:City>Rome</place:City></rdf:li></rdf:Bag></place:LocationShown></rdf:Description>"#,
r#"<rdf:Description><place:LocationShown><rdf:Bag><rdf:li></rdf:li></rdf:Bag></place:LocationShown></rdf:Description>"#,
r#"</rdf:RDF>"#
);
let parsed = parse_xmp(packet).expect("valid XMP");
assert!(parsed.has_location_metadata(), "the earlier city is still in the packet");
let city = parsed
.values
.get("Iptc4xmpExt:LocationShown")
.and_then(|value| value.items().first())
.and_then(|item| item.field("Iptc4xmpExt:City"))
.and_then(XmpValue::text);
assert_eq!(city, Some("Rome"));
}
/// GPS nested in a struct array item flattens to the item's (empty) text; the tree inventory still
/// names it.
#[test]
fn gps_inside_a_bag_item_counts_as_location() {
let packet = concat!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:o="urn:other">"#,
r#"<rdf:Description><o:Shots><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
r#"<exif:GPSLatitude>37,46.494N</exif:GPSLatitude></rdf:li></rdf:Bag></o:Shots></rdf:Description></rdf:RDF>"#
);
let parsed = parse_xmp(packet).expect("valid XMP");
assert!(parsed.properties.keys().all(|key| !key.contains("GPS")), "flattening drops the item's fields");
assert!(parsed.names_location);
assert!(parsed.has_location_metadata());
}
/// A location struct nested in another struct is judged by its own fields, not by unrelated text
/// next to it.
#[test]
fn a_nested_location_is_judged_by_its_own_fields() {
let packet = |city: &str| {
format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:place="http://iptc.org/std/Iptc4xmpExt/2008-02-29/" xmlns:o="urn:other"><rdf:Description><o:Work rdf:parseType="Resource"><o:Title>Bridge</o:Title><place:LocationCreated><rdf:Bag><rdf:li rdf:parseType="Resource"><place:City>{city}</place:City></rdf:li></rdf:Bag></place:LocationCreated></o:Work></rdf:Description></rdf:RDF>"#
)
};
assert!(!parse_xmp(&packet("")).expect("valid XMP").has_location_metadata(), "a blank place beside a title");
assert!(parse_xmp(&packet("Rome")).expect("valid XMP").has_location_metadata());
}
/// A place inside a struct array item has no flattened key of its own; the tree inventory finds
/// it, and a blank one there still doesn't count.
#[test]
fn a_location_inside_an_array_item_counts_unless_blank() {
let packet = |city: &str| {
format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:place="http://iptc.org/std/Iptc4xmpExt/2008-02-29/" xmlns:o="urn:other"><rdf:Description><o:Shots><rdf:Bag><rdf:li rdf:parseType="Resource"><place:LocationCreated><rdf:Bag><rdf:li rdf:parseType="Resource"><place:City>{city}</place:City></rdf:li></rdf:Bag></place:LocationCreated></rdf:li></rdf:Bag></o:Shots></rdf:Description></rdf:RDF>"#
)
};
let with_city = parse_xmp(&packet("Rome")).expect("valid XMP");
assert!(with_city.properties.keys().all(|key| !key.contains("Location")), "flattening drops the item's fields");
assert!(with_city.has_location_metadata());
assert!(!parse_xmp(&packet(" ")).expect("valid XMP").has_location_metadata(), "a blank place is no location");
}
/// A struct repeated in one description keeps its first copy in `values` when that copy has text
/// elsewhere; a place only the later copy names must still be found.
#[test]
fn a_place_in_a_later_repeated_struct_counts() {
let packet = concat!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:place="http://iptc.org/std/Iptc4xmpExt/2008-02-29/" xmlns:o="urn:other"><rdf:Description>"#,
r#"<o:Work rdf:parseType="Resource"><o:Title>Bridge</o:Title><place:LocationCreated><rdf:Bag><rdf:li rdf:parseType="Resource"><place:City></place:City></rdf:li></rdf:Bag></place:LocationCreated></o:Work>"#,
r#"<o:Work rdf:parseType="Resource"><place:LocationCreated><rdf:Bag><rdf:li rdf:parseType="Resource"><place:City>Rome</place:City></rdf:li></rdf:Bag></place:LocationCreated></o:Work>"#,
r#"</rdf:Description></rdf:RDF>"#
);
assert!(parse_xmp(packet).expect("valid XMP").has_location_metadata());
}
/// The same `rdf:Description` can repeat `LocationShown`; a later blank item must not replace the
/// city of the earlier one (its `rdf:li` flattens to empty text, so only the structured value has it).
#[test]
fn a_repeated_empty_location_in_one_description_keeps_the_city() {
let packet = concat!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:place="http://iptc.org/std/Iptc4xmpExt/2008-02-29/">"#,
r#"<rdf:Description><place:LocationShown><rdf:Bag><rdf:li rdf:parseType="Resource">"#,
r#"<place:City>Rome</place:City></rdf:li></rdf:Bag></place:LocationShown>"#,
r#"<place:LocationShown><rdf:Bag><rdf:li></rdf:li></rdf:Bag></place:LocationShown></rdf:Description>"#,
r#"</rdf:RDF>"#
);
let parsed = parse_xmp(packet).expect("valid XMP");
assert!(parsed.has_location_metadata(), "the earlier city is still in the packet");
let city = parsed
.values
.get("Iptc4xmpExt:LocationShown")
.and_then(|value| value.items().first())
.and_then(|item| item.field("Iptc4xmpExt:City"))
.and_then(XmpValue::text);
assert_eq!(city, Some("Rome"));
}
/// One walker answers both the parser and the Remove Location check: nested text counts, blanks
/// don't, and a tree past the node budget counts as text-bearing instead of being walked in full.
#[test]
fn value_has_text_finds_nested_text_and_stops_at_the_node_budget() {
let blank = XmpValue::Array(vec![XmpValue::Text(" ".into()), XmpValue::Struct(BTreeMap::new())]);
assert!(!value_has_text(&blank));
let nested = XmpValue::Array(vec![XmpValue::Struct(BTreeMap::from([("Iptc4xmpExt:City".to_string(), XmpValue::Text("Rome".into()))]))]);
assert!(value_has_text(&nested));
let oversized = XmpValue::Array(vec![XmpValue::Text(String::new()); MAX_NODES + 1]);
assert!(value_has_text(&oversized), "a tree past the node budget is treated as text-bearing");
let data = XmpData { values: BTreeMap::from([("Iptc4xmpExt:LocationShown".to_string(), nested)]), ..XmpData::default() };
let data = XmpData { properties: BTreeMap::from([("Iptc4xmpExt:LocationShown".to_string(), vec![String::new()])]), ..data };
assert!(data.has_location_metadata(), "the structured value's city is found");
}
+30 -4
View File
@@ -23,7 +23,7 @@ use std::collections::{BTreeSet, HashSet};
use quick_xml::events::Event;
use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, XmpNamespace, canonical_name, parse_xmp_with, unescape_attr};
use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, XmpNamespace, canonical_name, check_scopes, parse_xmp_with, unescape_attr};
/// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical
/// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace.
@@ -77,6 +77,8 @@ struct Scan {
descs: Vec<Desc>,
/// Where the first `</rdf:RDF>` starts.
rdf_close: Option<usize>,
/// The span of a self-closing `<rdf:RDF/>` (used only when there is no `</rdf:RDF>`).
rdf_empty: Option<(usize, usize)>,
}
enum Kind {
@@ -134,6 +136,7 @@ fn scan(s: &str, extra: &[XmpNamespace]) -> Result<Scan, XmpError> {
raw_attrs.push((k, v));
}
scopes.push(scope);
check_scopes(&scopes)?;
let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
let name = canonical_name(&raw_name, &scopes, extra, &mut opaque_budget)?;
let kind = match stack.last() {
@@ -150,6 +153,9 @@ fn scan(s: &str, extra: &[XmpNamespace]) -> Result<Scan, XmpError> {
_ => Kind::Other,
};
if empty {
if matches!(kind, Kind::Rdf) {
out.rdf_empty = out.rdf_empty.or(Some((before, after)));
}
scopes.pop();
if let Kind::Prop { desc, start, name } = kind
&& let Some(d) = out.descs.get_mut(desc)
@@ -215,7 +221,9 @@ pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result<Strin
};
parse_xmp_with(old, rules.namespaces)?;
let ex = scan(old, rules.namespaces)?;
let rdf_close = ex.rdf_close.ok_or_else(|| XmpError::Xml("no rdf:RDF element".into()))?;
if ex.rdf_close.is_none() && ex.rdf_empty.is_none() {
return Err(XmpError::Xml("no rdf:RDF element".into()));
}
let new = scan(fresh, rules.namespaces)?;
let fresh_desc = new.descs.first().ok_or_else(|| XmpError::Xml("the new packet has no rdf:Description".into()))?;
let fresh_names: BTreeSet<&str> = fresh_desc.props.iter().map(|p| p.name.as_str()).collect();
@@ -263,8 +271,16 @@ pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result<Strin
}
block.push_str("\n </rdf:Description>");
if written > 0 {
let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len());
edits.push((at, at, block));
if let Some(rdf_close) = ex.rdf_close {
let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len());
edits.push((at, at, block));
} else if let Some((s, e)) = ex.rdf_empty {
// a self-closing `<rdf:RDF .../>` is opened up around the new description
let tag = old.get(s..e).ok_or_else(|| XmpError::Xml("bad span".into()))?;
let open = tag.trim_end().strip_suffix("/>").ok_or_else(|| XmpError::Xml("bad rdf:RDF tag".into()))?;
let name = tag.get(1..).and_then(|t| t.split(|c: char| c.is_whitespace() || c == '/' || c == '>').next()).unwrap_or("rdf:RDF");
edits.push((s, e, format!("{open}>{block}\n</{name}>")));
}
}
edits.sort_by_key(|e| (e.0, e.1));
// overlapping edits can't happen (properties sit inside their description, which is either
@@ -404,6 +420,16 @@ mod tests {
assert_eq!(merge_xmp(&again, &fresh(1, &[]), RULES).unwrap(), again, "idempotent");
}
#[test]
fn a_self_closing_rdf_element_is_opened_not_replaced() {
let ex = "<!-- keep me --><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><r:RDF xmlns:r=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"/></x:xmpmeta>";
let out = merge_xmp(ex, &fresh(3, &[]), RULES).unwrap();
assert!(out.starts_with("<!-- keep me --><x:xmpmeta"), "{out}");
assert!(out.contains("</r:RDF></x:xmpmeta>"), "{out}");
assert_eq!(parse_xmp(&out).unwrap().properties.get("xmp:Rating"), Some(&vec!["3".to_string()]));
assert!(merge_xmp("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>", &fresh(3, &[]), RULES).is_err(), "no RDF element at all");
}
#[test]
fn owned_if_present_and_prefix_independence() {
// the existing packet binds the namespaces to other prefixes, and has a capture time
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "craft-pptx"
description = "PresentationML (ECMA-376 part 1, chapter 19): the model of a .pptx presentation (masters, layouts, slides, shapes, placeholders, transitions and animations)"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-drawingml = { workspace = true }
kurbo = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["rc"] }
serde_json = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
# craft-pptx
PresentationML (ECMA-376 part 1, chapter 19): what a `.pptx` presentation holds, as plain Rust
types with serde. The DrawingML inside it (fills, lines, text bodies, tables, charts, themes)
comes from `craft-drawingml`.
| Item | What | Markup |
|---|---|---|
| `Presentation` | slide size, masters, slides, sections, custom shows, show settings, header and footer, properties, media, embedded fonts, table styles, tags, kept parts, drawing guides | `p:presentation`, `docProps/*`, `p:viewPr` |
| `Master`, `Layout` | theme, shapes, background, title/body/other text styles, colour map | `p:sldMaster`, `p:sldLayout` |
| `Slide` | shapes, background, transition, animations, notes, comments, colour map override | `p:sld`, `p:notes`, comments parts |
| `Shape`, `ShapeKind` | shapes, pictures, groups, connectors, tables, charts, media, ink, 3-D models, and objects kept as XML | `p:sp`, `p:pic`, `p:grpSp`, `p:cxnSp`, `p:graphicFrame`, `p:contentPart` |
| `Placeholder`, `PhType`, `placeholder` | placeholders and the rule that matches a slide placeholder to its layout and master parents | `p:ph` |
| `anim` | transitions and the flat list of animations the timing tree stands for, with the effect catalogues | `p:transition`, `p:timing` |
Ids (`ShapeId`, `LayoutId`, `MasterId`, and `MediaId`, `SlideId` from `craft-drawingml`) are
unique across a presentation; `Presentation::alloc_id` hands out fresh ones and refuses once they
run out. `Presentation::default()` is an empty 16:9 deck with no masters: an application adds its
own starting masters and layouts.
The reader and writer come in later changes.
+668
View File
@@ -0,0 +1,668 @@
//! Slide transitions and object animations.
//!
//! Animations are kept as the flat, ordered list the Animation Pane shows; the PPTX writer builds
//! the nested timing tree from it and the reader flattens files back into it.
use serde::{Deserialize, Serialize};
use crate::ShapeId;
use craft_drawingml::{style, text};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Transition {
/// Effect id: `none`, `fade`, `push`, `wipe`, `split`, `reveal`, `cut`, `randomBar`, `shape`,
/// `uncover`, `cover`, `flash`, `morph`, `dissolve`, `checker`, `blinds`, `clock`, `zoom`… (see
/// [`TRANSITIONS`]).
pub kind: String,
/// Effect option, e.g. direction `l`, `r`, `u`, `d`, `horz`, `vert`, `in`, `out`.
pub option: String,
pub duration_ms: u32,
pub advance_on_click: bool,
pub advance_after_ms: Option<u32>,
/// Embedded sound media.
pub sound: Option<crate::MediaId>,
/// Loop Until Next Sound (`p:stSnd loop="1"`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub sound_loop: bool,
/// [Stop Previous Sound] (`p:endSnd`): the slide stops a sound still playing.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub stop_sound: bool,
/// Unmodelled transition XML kept for round-trip.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
}
impl Default for Transition {
fn default() -> Self {
Transition {
kind: "none".into(),
option: String::new(),
duration_ms: 1000,
advance_on_click: true,
advance_after_ms: None,
sound: None,
sound_loop: false,
stop_sound: false,
raw: None,
}
}
}
/// (id, label, category, default duration ms, effect options)
pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[
("none", "None", "Subtle", 0, &[]),
("morph", "Morph", "Subtle", 2000, &["objects", "words", "characters"]),
("fade", "Fade", "Subtle", 700, &["smoothly", "throughBlack"]),
("push", "Push", "Subtle", 1000, &["u", "d", "l", "r"]),
("wipe", "Wipe", "Subtle", 1000, &["r", "l", "u", "d", "ru", "lu", "rd", "ld"]),
("split", "Split", "Subtle", 1500, &["vertOut", "vertIn", "horzOut", "horzIn"]),
("reveal", "Reveal", "Subtle", 2000, &["smoothlyLeft", "smoothlyRight", "blackLeft", "blackRight"]),
("cut", "Cut", "Subtle", 0, &["", "throughBlack"]),
("randomBar", "Random Bars", "Subtle", 1000, &["vert", "horz"]),
("shape", "Shape", "Subtle", 2000, &["circle", "diamond", "plus", "in", "out"]),
("uncover", "Uncover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]),
("cover", "Cover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]),
("flash", "Flash", "Subtle", 1000, &[]),
("fallOver", "Fall Over", "Exciting", 2000, &["l", "r"]),
("drape", "Drape", "Exciting", 2000, &["l", "r"]),
("curtains", "Curtains", "Exciting", 2000, &[]),
("wind", "Wind", "Exciting", 2000, &["l", "r"]),
("prestige", "Prestige", "Exciting", 2000, &[]),
("fracture", "Fracture", "Exciting", 2000, &[]),
("crush", "Crush", "Exciting", 2000, &[]),
("peelOff", "Peel Off", "Exciting", 1500, &["l", "r"]),
("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r", "singleL", "singleR"]),
("airplane", "Airplane", "Exciting", 2000, &["l", "r"]),
("origami", "Origami", "Exciting", 2000, &["l", "r"]),
("dissolve", "Dissolve", "Exciting", 1200, &[]),
("checker", "Checkerboard", "Exciting", 1500, &["horz", "vert"]),
("blinds", "Blinds", "Exciting", 1600, &["vert", "horz"]),
("clock", "Clock", "Exciting", 2000, &["clockwise", "counterClockwise", "wedge"]),
("ripple", "Ripple", "Exciting", 1400, &["center", "lu", "ru", "ld", "rd"]),
("honeycomb", "Honeycomb", "Exciting", 3000, &[]),
("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d", "hexL", "hexR", "hexU", "hexD"]),
("vortex", "Vortex", "Exciting", 3000, &["l", "r", "u", "d"]),
("shred", "Shred", "Exciting", 2000, &["stripsIn", "stripsOut", "particlesIn", "particlesOut"]),
("switch", "Switch", "Exciting", 1250, &["l", "r"]),
("flip", "Flip", "Exciting", 1250, &["l", "r"]),
("gallery", "Gallery", "Exciting", 1500, &["l", "r"]),
("cube", "Cube", "Exciting", 1250, &["l", "r", "u", "d"]),
("doors", "Doors", "Exciting", 1400, &["vert", "horz"]),
("box", "Box", "Exciting", 1250, &["l", "r", "u", "d"]),
("comb", "Comb", "Exciting", 1000, &["horz", "vert"]),
("zoom", "Zoom", "Exciting", 1000, &["in", "out", "rotate"]),
("random", "Random", "Exciting", 2000, &[]),
("pan", "Pan", "Dynamic Content", 1000, &["u", "d", "l", "r"]),
("ferris", "Ferris Wheel", "Dynamic Content", 2000, &["l", "r"]),
("conveyor", "Conveyor", "Dynamic Content", 2000, &["l", "r"]),
("rotate", "Rotate", "Dynamic Content", 2000, &["l", "r", "u", "d"]),
("window", "Window", "Dynamic Content", 1250, &["vert", "horz"]),
("orbit", "Orbit", "Dynamic Content", 2000, &["l", "r", "u", "d"]),
("flythrough", "Fly Through", "Dynamic Content", 1000, &["in", "out", "inBounce", "outBounce"]),
];
/// The Effect Options label PowerPoint shows for a transition option. Options are stored as the
/// file format's values: for moving transitions (Push, Wipe, Cover, Pan, Cube…) the direction
/// things move, so "l" is the effect coming "From Right"; Glitter and the preset effects (Fall
/// Over, Wind…) name the side itself; Ripple names where the wave heads.
pub fn transition_option_label(kind: &str, option: &str) -> String {
let side = |c: char| match c {
'l' => "Left",
'r' => "Right",
'u' => "Top",
_ => "Bottom",
};
let opposite = |c: char| match c {
'l' => 'r',
'r' => 'l',
'u' => 'd',
_ => 'u',
};
let corner = |o: &str, flip: bool| -> Option<String> {
let mut cs = o.chars();
let (h, v) = (cs.next()?, cs.next()?);
let (h, v) = if flip { (opposite(h), opposite(v)) } else { (h, v) };
Some(format!("From {}-{}", side(v), side(h)))
};
let moving = matches!(
kind,
"push"
| "wipe"
| "cover"
| "uncover"
| "pan"
| "vortex"
| "cube"
| "box"
| "rotate"
| "orbit"
| "gallery"
| "conveyor"
| "ferris"
| "switch"
| "flip"
);
match (kind, option) {
(_, "horz") => "Horizontal".into(),
(_, "vert") => "Vertical".into(),
("split", o) => format!("{} {}", if o.starts_with("horz") { "Horizontal" } else { "Vertical" }, if o.ends_with("In") { "In" } else { "Out" }),
("fade" | "cut", "throughBlack") => "Through Black".into(),
("fade", _) => "Smoothly".into(),
("cut", _) => "Cut".into(),
("clock", "counterClockwise") => "Counterclockwise".into(),
("clock", "wedge") => "Wedge".into(),
("clock", _) => "Clockwise".into(),
("zoom", "rotate") => "Zoom and Rotate".into(),
("ripple", "center") => "Center".into(),
("ripple", o) => corner(o, true).unwrap_or_else(|| "Center".into()),
("glitter", o) => {
let (pat, d) = match o.strip_prefix("hex") {
Some(d) => ("Hexagons", d.to_ascii_lowercase()),
None => ("Diamonds", o.to_string()),
};
format!("{pat} From {}", side(d.chars().next().unwrap_or('l')))
}
("pageCurlDouble", o) => match o {
"singleL" => "Single Left".into(),
"singleR" => "Single Right".into(),
"r" => "Double Right".into(),
_ => "Double Left".into(),
},
("shred", o) => {
format!("{} {}", if o.starts_with("particles") { "Particles" } else { "Strips" }, if o.ends_with("Out") { "Out" } else { "In" })
}
("reveal", o) => format!(
"{} {}",
if o.starts_with("black") { "Through Black" } else { "Smoothly" },
if o.ends_with("Right") { "From Right" } else { "From Left" }
),
("flythrough", o) => {
format!("{}{}", if o.starts_with("out") { "Out" } else { "In" }, if o.ends_with("Bounce") { " with Bounce" } else { "" })
}
("morph", o) => match o {
"words" => "Words".into(),
"characters" => "Characters".into(),
_ => "Objects".into(),
},
(_, o) if o.len() == 2 && moving => corner(o, true).unwrap_or_default(),
(_, o) if o.len() == 1 && moving => format!("From {}", side(opposite(o.chars().next().unwrap_or('l')))),
(_, "l") => "Left".into(),
(_, "r") => "Right".into(),
(_, "u") => "Top".into(),
(_, "d") => "Bottom".into(),
(_, "in") => "In".into(),
(_, "out") => "Out".into(),
(_, o) => o.to_string(),
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AnimClass {
#[default]
Entrance,
Emphasis,
Exit,
Path,
Media,
}
impl AnimClass {
pub fn xml(self) -> &'static str {
match self {
AnimClass::Entrance => "entr",
AnimClass::Emphasis => "emph",
AnimClass::Exit => "exit",
AnimClass::Path => "path",
AnimClass::Media => "mediacall",
}
}
pub fn from_xml(s: &str) -> Self {
match s {
"emph" => AnimClass::Emphasis,
"exit" => AnimClass::Exit,
"path" => AnimClass::Path,
"mediacall" => AnimClass::Media,
_ => AnimClass::Entrance,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AnimStart {
#[default]
OnClick,
WithPrevious,
AfterPrevious,
}
/// Text build: as one object, all at once by paragraph, or by paragraph level.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextBuild {
#[default]
AsOne,
ByParagraph,
ByWord,
ByLetter,
}
/// Most paragraphs a by-paragraph build animates separately; later ones follow the last of them.
pub const MAX_BUILD_PARAGRAPHS: usize = 1_000;
/// What a paragraph does in a by-paragraph build.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BuildRole {
/// Animated on its own: a filled paragraph at the shallowest filled level.
Top,
/// Follows the top paragraph with this index (deeper, empty or blank paragraphs after it).
Member(usize),
/// Before the first top paragraph: not animated.
Unowned,
}
/// Does the paragraph show any text? A by-paragraph build doesn't animate blank ones on their own.
pub fn build_filled(p: &text::Paragraph) -> bool {
p.runs.iter().any(|r| !r.text.trim().is_empty())
}
/// The role of every paragraph of `text` in a by-paragraph build, by paragraph index: what the
/// timeline plays, and what the PPTX reader and writer map to one file effect per paragraph.
pub fn paragraph_build_roles(text: &text::TextBody) -> Vec<BuildRole> {
let top = text.paragraphs.iter().filter(|p| build_filled(p)).map(|p| p.level).min().unwrap_or(0);
let mut tops = 0;
let mut owner: Option<usize> = None;
text.paragraphs
.iter()
.enumerate()
.map(|(i, p)| {
if p.level <= top && build_filled(p) && tops < MAX_BUILD_PARAGRAPHS {
tops += 1;
owner = Some(i);
BuildRole::Top
} else {
owner.map_or(BuildRole::Unowned, BuildRole::Member)
}
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Animation {
pub shape: ShapeId,
pub class: AnimClass,
/// Effect id from [`ANIMATIONS`] (e.g. `fade`, `fly`, `zoom`, `spin`, `pulse`, `lines`…).
pub effect: String,
/// Effect option (direction etc.).
pub option: String,
pub start: AnimStart,
pub duration_ms: u32,
pub delay_ms: u32,
/// Repeat count (0/1 = once); `u32::MAX` = until next click.
pub repeat: u32,
pub rewind: bool,
pub text_build: TextBuild,
/// Paragraph index when the effect animates one paragraph of a text body.
pub paragraph: Option<u32>,
/// Motion path in slide-relative units (SVG-like `M 0 0 L 0.25 0 E`).
pub path: Option<String>,
/// Triggered by a click on another shape.
pub trigger: Option<ShapeId>,
/// Emphasis amount: spin degrees, grow/shrink scale (1.5 = 150%), transparency…
pub amount: Option<f64>,
/// Emphasis colour (fill/line/font colour change).
pub color: Option<style::ColorRef>,
/// PowerPoint preset id/subtype read from or written to files.
pub preset_id: Option<u32>,
pub preset_subtype: Option<u32>,
/// After animation: dim to colour, hide, hide on next click.
pub after: Option<String>,
pub sound: Option<crate::MediaId>,
pub smooth_start: f64,
pub smooth_end: f64,
pub bounce_end: f64,
pub auto_reverse: bool,
}
impl Default for Animation {
fn default() -> Self {
Animation {
shape: ShapeId(0),
class: AnimClass::Entrance,
effect: "fade".into(),
option: String::new(),
start: AnimStart::OnClick,
duration_ms: 500,
delay_ms: 0,
repeat: 1,
rewind: false,
text_build: TextBuild::AsOne,
paragraph: None,
path: None,
trigger: None,
amount: None,
color: None,
preset_id: None,
preset_subtype: None,
after: None,
sound: None,
smooth_start: 0.0,
smooth_end: 0.0,
bounce_end: 0.0,
auto_reverse: false,
}
}
}
/// One [`ANIMATIONS`] row: (id, label, class, PowerPoint preset id, default duration ms, options).
pub type AnimationInfo = (&'static str, &'static str, AnimClass, u32, u32, &'static [&'static str]);
/// (id, label, class, PowerPoint preset id, default duration ms, options)
pub static ANIMATIONS: &[AnimationInfo] = &[
("appear", "Appear", AnimClass::Entrance, 1, 0, &[]),
("fade", "Fade", AnimClass::Entrance, 10, 500, &[]),
("fly", "Fly In", AnimClass::Entrance, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]),
("float", "Float In", AnimClass::Entrance, 42, 1000, &["u", "d"]),
("split", "Split", AnimClass::Entrance, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]),
("wipe", "Wipe", AnimClass::Entrance, 22, 500, &["b", "l", "t", "r"]),
("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]),
("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]),
("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]),
("grow", "Grow & Turn", AnimClass::Entrance, 31, 1000, &[]),
("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]),
("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]),
("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]),
("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]),
("box", "Box", AnimClass::Entrance, 4, 2000, &["in", "out"]),
("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]),
("circle", "Circle", AnimClass::Entrance, 6, 2000, &["in", "out"]),
("diamond", "Diamond", AnimClass::Entrance, 8, 2000, &["in", "out"]),
("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]),
("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]),
("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]),
("expand", "Expand", AnimClass::Entrance, 55, 1000, &[]),
("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]),
("spinner", "Spinner", AnimClass::Entrance, 49, 500, &[]),
("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]),
("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 250, &[]),
("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]),
("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]),
("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]),
("desaturate", "Desaturate", AnimClass::Emphasis, 25, 500, &[]),
("darken", "Darken", AnimClass::Emphasis, 24, 500, &[]),
("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]),
("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]),
("objectColor", "Object Color", AnimClass::Emphasis, 19, 500, &[]),
("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]),
("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]),
("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]),
("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]),
("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]),
("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 2000, &[]),
("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]),
("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]),
("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]),
("flyOut", "Fly Out", AnimClass::Exit, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]),
("floatOut", "Float Out", AnimClass::Exit, 42, 1000, &["u", "d"]),
("splitOut", "Split", AnimClass::Exit, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]),
("wipeOut", "Wipe", AnimClass::Exit, 22, 500, &["b", "l", "t", "r"]),
("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]),
("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]),
("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]),
("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 1000, &[]),
("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]),
("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]),
("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]),
("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]),
("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]),
("boxOut", "Box", AnimClass::Exit, 4, 2000, &["in", "out"]),
("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]),
("diamondOut", "Diamond", AnimClass::Exit, 8, 2000, &["in", "out"]),
("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]),
("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]),
("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]),
("spinnerOut", "Spinner", AnimClass::Exit, 49, 500, &[]),
("contract", "Contract", AnimClass::Exit, 55, 1000, &[]),
("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]),
("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]),
("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]),
(
"shapes",
"Shapes",
AnimClass::Path,
1,
2000,
&["circle", "diamond", "hexagon", "triangle", "square", "trapezoid", "octagon", "parallelogram", "pentagon"],
),
("loops", "Loops", AnimClass::Path, 61, 2000, &[]),
("diagonalDownRight", "Diagonal Down Right", AnimClass::Path, 49, 2000, &[]),
("diagonalUpRight", "Diagonal Up Right", AnimClass::Path, 56, 2000, &[]),
("zigzag", "Zigzag", AnimClass::Path, 38, 2000, &[]),
("sineWave", "Sine Wave", AnimClass::Path, 40, 2000, &[]),
("pathWave", "Wave", AnimClass::Path, 47, 2000, &[]),
("decayingWave", "Decaying Wave", AnimClass::Path, 60, 2000, &[]),
("heartbeat", "Heartbeat", AnimClass::Path, 45, 2000, &[]),
("sCurve1", "S Curve 1", AnimClass::Path, 59, 2000, &[]),
("sCurve2", "S Curve 2", AnimClass::Path, 39, 2000, &[]),
("stairsDown", "Stairs Down", AnimClass::Path, 62, 2000, &[]),
("bounceRight", "Bounce Right", AnimClass::Path, 54, 2000, &[]),
("bounceLeft", "Bounce Left", AnimClass::Path, 41, 2000, &[]),
("spiralRight", "Spiral Right", AnimClass::Path, 46, 2000, &[]),
("spiralLeft", "Spiral Left", AnimClass::Path, 55, 2000, &[]),
("spring", "Spring", AnimClass::Path, 53, 2000, &[]),
("funnel", "Funnel", AnimClass::Path, 52, 2000, &[]),
("swoosh", "Swoosh", AnimClass::Path, 30, 2000, &[]),
("star4", "4 Point Star", AnimClass::Path, 16, 2000, &[]),
("star5", "5 Point Star", AnimClass::Path, 5, 2000, &[]),
("star6", "6 Point Star", AnimClass::Path, 11, 2000, &[]),
("star8", "8 Point Star", AnimClass::Path, 17, 2000, &[]),
("heart", "Heart", AnimClass::Path, 9, 2000, &[]),
("crescentMoon", "Crescent Moon", AnimClass::Path, 6, 2000, &[]),
("teardrop", "Teardrop", AnimClass::Path, 18, 2000, &[]),
("football", "Football", AnimClass::Path, 12, 2000, &[]),
("plusPath", "Plus", AnimClass::Path, 32, 2000, &[]),
("rightTriangle", "Right Triangle", AnimClass::Path, 2, 2000, &[]),
("curvedSquare", "Curved Square", AnimClass::Path, 20, 2000, &[]),
("figure8Horizontal", "Horizontal Figure 8", AnimClass::Path, 26, 2000, &[]),
("figure8Vertical", "Vertical Figure 8", AnimClass::Path, 22, 2000, &[]),
("loopDeLoop", "Loop de Loop", AnimClass::Path, 24, 2000, &[]),
("bean", "Bean", AnimClass::Path, 31, 2000, &[]),
("peanut", "Peanut", AnimClass::Path, 27, 2000, &[]),
("neutron", "Neutron", AnimClass::Path, 29, 2000, &[]),
("customPath", "Custom Path", AnimClass::Path, 0, 2000, &[]),
("play", "Play", AnimClass::Media, 1, 0, &[]),
("pause", "Pause", AnimClass::Media, 2, 0, &[]),
("stop", "Stop", AnimClass::Media, 3, 0, &[]),
];
pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static AnimationInfo> {
ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect))
}
/// The path of a motion-path preset beyond Lines, Arcs, Turns, Shapes and Loops, in slide
/// fractions from the shape's position (our path syntax: `M`, `L`, `Z`, ending in `E`).
/// `None` for an effect that isn't one of these presets.
pub fn preset_path(effect: &str) -> Option<String> {
use std::f64::consts::{PI, TAU};
use std::fmt::Write;
// Sample `f` over 0..=1 into a polyline starting at (0, 0).
fn poly(n: usize, closed: bool, f: impl Fn(f64) -> (f64, f64)) -> String {
let (x0, y0) = f(0.0);
let mut s = String::from("M 0 0");
for k in 1..=n {
let (x, y) = f(k as f64 / n as f64);
let _ = write!(s, " L {:.4} {:.4}", x - x0, y - y0);
}
if closed {
s.push_str(" Z");
}
s.push_str(" E");
s
}
fn pts(p: &[(f64, f64)], closed: bool) -> String {
let mut s = String::from("M 0 0");
for (x, y) in p {
let _ = write!(s, " L {x:.4} {y:.4}");
}
if closed {
s.push_str(" Z");
}
s.push_str(" E");
s
}
// A closed curve in polar form r(θ), starting at its top.
fn polar(scale: f64, r: impl Fn(f64) -> f64) -> String {
poly(96, true, |t| {
let a = -PI / 2.0 + TAU * t;
let rr = r(a + PI / 2.0) * scale;
(rr * a.cos(), rr * a.sin())
})
}
fn star(n: usize, outer: f64, inner: f64) -> String {
let mut p = Vec::new();
let start = (0.0, -outer);
for k in 1..=2 * n {
let a = -PI / 2.0 + PI * k as f64 / n as f64;
let r = if k % 2 == 1 { inner } else { outer };
p.push((r * a.cos() - start.0, r * a.sin() - start.1));
}
pts(&p, true)
}
let spiral = |dir: f64| {
poly(120, false, move |t| {
let r = 0.12 * (1.0 - t);
let a = dir * 3.0 * TAU * t;
(dir * (0.12 - r * a.cos()), -r * a.sin())
})
};
let bounce = |dir: f64| poly(90, false, move |t| (dir * 0.3 * t, 0.15 * t - 0.1 * (1.0 - t) * (3.0 * PI * t).sin().abs()));
Some(match effect {
"diagonalDownRight" => pts(&[(0.2, 0.2)], false),
"diagonalUpRight" => pts(&[(0.2, -0.2)], false),
"zigzag" => pts(&[(0.05, 0.04), (0.1, 0.0), (0.15, 0.04), (0.2, 0.0), (0.25, 0.04), (0.3, 0.0)], false),
"sineWave" => poly(64, false, |t| (0.3 * t, 0.04 * (2.0 * TAU * t).sin())),
"pathWave" => poly(48, false, |t| (0.3 * t, 0.06 * (TAU * t).sin())),
"decayingWave" => poly(96, false, |t| (0.3 * t, 0.07 * (-3.0 * t).exp() * (3.0 * TAU * t).sin())),
"heartbeat" => pts(&[(0.08, 0.0), (0.1, -0.08), (0.13, 0.06), (0.15, 0.0), (0.25, 0.0)], false),
"sCurve1" => poly(48, false, |t| (0.2 * t, 0.1 - 0.1 * (PI * t).cos())),
"sCurve2" => poly(48, false, |t| (0.1 - 0.1 * (PI * t).cos(), 0.2 * t)),
"stairsDown" => pts(&[(0.06, 0.0), (0.06, 0.06), (0.12, 0.06), (0.12, 0.12), (0.18, 0.12), (0.18, 0.18)], false),
"bounceRight" => bounce(1.0),
"bounceLeft" => bounce(-1.0),
"spiralRight" => spiral(1.0),
"spiralLeft" => spiral(-1.0),
"spring" => poly(160, false, |t| (0.3 * t + 0.03 * (5.0 * TAU * t).cos(), 0.03 * (5.0 * TAU * t).sin())),
"funnel" => poly(160, false, |t| {
let r = 0.12 * (1.0 - t);
(r * (3.0 * TAU * t).cos(), 0.25 * t + 0.03 * (3.0 * TAU * t).sin() * (1.0 - t))
}),
"swoosh" => poly(48, false, |t| (0.3 * t, -0.12 * (PI * t).sin() + 0.1 * t * t)),
"star4" => star(4, 0.1, 0.04),
"star5" => star(5, 0.1, 0.04),
"star6" => star(6, 0.1, 0.05),
"star8" => star(8, 0.1, 0.06),
"heart" => poly(96, true, |t| {
let a = TAU * t;
(0.006 * 16.0 * a.sin().powi(3), -0.006 * (13.0 * a.cos() - 5.0 * (2.0 * a).cos() - 2.0 * (3.0 * a).cos() - (4.0 * a).cos()))
}),
"crescentMoon" => {
// The outer arc of a circle, back along a smaller, offset one.
let mut p: Vec<(f64, f64)> = (1..=32)
.map(|k| {
let a = -PI / 2.0 - PI * k as f64 / 32.0;
(0.1 * a.cos(), 0.1 + 0.1 * a.sin())
})
.collect();
p.extend((1..=32).map(|k| {
let a = PI / 2.0 - PI * k as f64 / 32.0;
(0.06 * a.cos() - 0.03, 0.1 + 0.1 * a.sin())
}));
pts(&p, true)
}
"teardrop" => polar(0.1, |a| 0.6 + 0.4 * (a / 2.0).cos().abs().powf(3.0)),
"football" => poly(64, true, |t| {
let a = TAU * t;
(0.12 * a.sin(), 0.06 * (1.0 - a.cos()) * (1.0 - 0.3 * (2.0 * a).cos().abs()))
}),
"plusPath" => pts(
&[
(0.04, 0.0),
(0.04, 0.04),
(0.08, 0.04),
(0.08, 0.08),
(0.04, 0.08),
(0.04, 0.12),
(0.0, 0.12),
(0.0, 0.08),
(-0.04, 0.08),
(-0.04, 0.04),
(0.0, 0.04),
],
true,
),
"rightTriangle" => pts(&[(0.0, 0.15), (0.15, 0.15)], true),
"curvedSquare" => poly(96, true, |t| {
let a = -PI / 2.0 + TAU * t;
let sg = |v: f64| v.signum() * v.abs().sqrt();
(0.1 * sg(a.cos()), 0.1 + 0.1 * sg(a.sin()))
}),
"figure8Horizontal" => poly(96, true, |t| {
let a = TAU * t;
(0.15 * a.sin(), 0.06 * (2.0 * a).sin())
}),
"figure8Vertical" => poly(96, true, |t| {
let a = TAU * t;
(0.06 * (2.0 * a).sin(), 0.15 * (1.0 - a.cos()))
}),
"loopDeLoop" => poly(120, false, |t| (0.3 * t - 0.05 * (2.0 * TAU * t).sin(), -0.05 * (1.0 - (2.0 * TAU * t).cos()))),
"bean" => polar(0.1, |a| 0.75 + 0.25 * a.cos() + 0.15 * (2.0 * a).cos()),
"peanut" => polar(0.1, |a| 0.7 + 0.3 * (2.0 * a).cos().abs()),
"neutron" => polar(0.1, |a| 0.35 + 0.65 * (3.0 * a).cos().abs()),
_ => return None,
})
}
#[cfg(test)]
mod option_label_tests {
use super::*;
#[test]
fn transition_option_labels() {
assert_eq!(transition_option_label("push", "l"), "From Right");
assert_eq!(transition_option_label("push", "u"), "From Bottom");
assert_eq!(transition_option_label("wipe", "r"), "From Left");
assert_eq!(transition_option_label("cover", "ru"), "From Bottom-Left");
assert_eq!(transition_option_label("ripple", "ru"), "From Bottom-Left");
assert_eq!(transition_option_label("glitter", "hexU"), "Hexagons From Top");
assert_eq!(transition_option_label("glitter", "l"), "Diamonds From Left");
assert_eq!(transition_option_label("wind", "l"), "Left");
assert_eq!(transition_option_label("pageCurlDouble", "singleR"), "Single Right");
assert_eq!(transition_option_label("shred", "particlesOut"), "Particles Out");
assert_eq!(transition_option_label("zoom", "rotate"), "Zoom and Rotate");
assert_eq!(transition_option_label("split", "vertIn"), "Vertical In");
for (id, _, _, _, opts) in TRANSITIONS {
for o in opts.iter() {
assert!(!transition_option_label(id, o).is_empty(), "{id}/{o}");
}
}
}
}
#[cfg(test)]
mod build_role_tests {
use super::*;
use text::TextBody;
#[test]
fn blank_and_deeper_paragraphs_follow_the_top_one_before_them() {
let mut t = TextBody::from_text("Before\nLead\n \nSub\nNext");
for (p, lvl) in t.paragraphs.iter_mut().zip([1, 0, 0, 1, 0]) {
p.level = lvl;
}
use BuildRole::*;
assert_eq!(paragraph_build_roles(&t), vec![Unowned, Top, Member(1), Member(1), Top]);
}
}
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
//! Placeholder inheritance: which layout and master placeholders a placeholder takes its
//! position, formatting and text styles from (`p:ph` `type` and `idx`, ECMA-376 part 1, 19.3.1.36).
//!
//! Only the matching lives here. What a caller does with the parents (effective position, fill,
//! text style) is its own business.
use crate::{Layout, Master, PhType, Shape};
/// Where a shape lives.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Owner {
Slide,
Layout,
Master,
}
/// Can a slide placeholder of type `slide` inherit from a layout placeholder of type `other`?
fn compatible(slide: PhType, other: PhType) -> bool {
use PhType::*;
match slide {
Title | CtrTitle => matches!(other, Title | CtrTitle),
SubTitle | Body | Obj | Chart | Table | ClipArt | Diagram | Media | Picture => {
!matches!(other, Title | CtrTitle | Date | Footer | SlideNum | Header)
}
k => k == other,
}
}
/// The placeholder in `shapes` that `shape` matches: by `idx` (when `by_idx`) and a compatible
/// type, else the same type, else a compatible one.
pub fn find_ph<'s>(shapes: &'s [Shape], shape: &Shape, by_idx: bool) -> Option<&'s Shape> {
let ph = shape.ph.as_ref()?;
if by_idx
&& ph.idx != 0
&& let Some(s) = shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.idx == ph.idx && compatible(ph.kind, p.kind)))
{
return Some(s);
}
// Same type first, then a compatible one.
shapes
.iter()
.find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind && (!by_idx || ph.idx == 0 || p.idx == ph.idx || ph.kind.is_title())))
.or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind)))
.or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| compatible(ph.kind, p.kind))))
}
/// The master placeholder a placeholder of type `kind` inherits from: the title, the matching
/// footer-row placeholder, or the body.
pub fn master_ph(master: &Master, kind: PhType) -> Option<&Shape> {
let want = match kind {
PhType::Title | PhType::CtrTitle => PhType::Title,
PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header => kind,
_ => PhType::Body,
};
master.shapes.iter().find(|s| s.ph_type() == Some(want))
}
/// The layout and master placeholders `shape` inherits from. A slide's placeholder matches one on
/// `layout`, and the master placeholder follows the layout one's type; a layout's placeholder
/// inherits from the master only; a master's from nothing.
pub fn parents<'a>(master: &'a Master, layout: Option<&'a Layout>, owner: Owner, shape: &Shape) -> (Option<&'a Shape>, Option<&'a Shape>) {
let Some(ph) = shape.ph.as_ref() else { return (None, None) };
match owner {
Owner::Slide => {
let lp = layout.and_then(|l| find_ph(&l.shapes, shape, true));
let kind = lp.and_then(Shape::ph_type).unwrap_or(ph.kind);
(lp, master_ph(master, kind))
}
Owner::Layout => (None, master_ph(master, ph.kind)),
Owner::Master => (None, None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Placeholder, ShapeId};
fn ph(id: u32, kind: PhType, idx: u32) -> Shape {
Shape { id: ShapeId(id), ph: Some(Placeholder { kind, idx, ..Default::default() }), ..Default::default() }
}
#[test]
fn slide_placeholders_match_by_index_then_type() {
let master = Master { shapes: vec![ph(1, PhType::Title, 0), ph(2, PhType::Body, 1), ph(3, PhType::SlideNum, 12)], ..Default::default() };
let layout = Layout { shapes: vec![ph(10, PhType::Title, 0), ph(11, PhType::Body, 1), ph(12, PhType::Obj, 2)], ..Default::default() };
let id = |s: Option<&Shape>| s.map(|s| s.id.0);
// By idx: idx 2 is the layout's obj, whose master parent is the body.
let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(20, PhType::Body, 2));
assert_eq!((id(l), id(m)), (Some(12), Some(2)));
// A centred title takes the layout's title and the master's title.
let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(21, PhType::CtrTitle, 0));
assert_eq!((id(l), id(m)), (Some(10), Some(1)));
// An unknown idx falls back to the same type.
let (l, _) = parents(&master, Some(&layout), Owner::Slide, &ph(22, PhType::Body, 9));
assert_eq!(id(l), Some(11));
// A title never inherits from a body, nor a slide number from a title.
assert_eq!(find_ph(&layout.shapes[1..], &ph(23, PhType::Title, 0), true).map(|s| s.id.0), None);
let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(24, PhType::SlideNum, 12));
assert_eq!((id(l), id(m)), (None, Some(3)));
// Layouts inherit from the master only, masters from nothing, other shapes from nothing.
assert_eq!(parents(&master, None, Owner::Layout, &ph(25, PhType::SubTitle, 1)), (None, master.shapes.get(1)));
assert_eq!(parents(&master, None, Owner::Master, &ph(26, PhType::Title, 0)), (None, None));
assert_eq!(parents(&master, Some(&layout), Owner::Slide, &Shape::default()), (None, None));
}
}
+131
View File
@@ -0,0 +1,131 @@
use super::*;
#[test]
fn serde_roundtrip() {
let mut p = Presentation::default();
p.masters.push(Arc::new(Master { layouts: vec![Layout { id: LayoutId(2), ..Default::default() }], ..Default::default() }));
p.slides.push(Arc::new(Slide { id: SlideId(3), layout: LayoutId(2), ..Default::default() }));
let j = serde_json::to_string(&p).unwrap();
let q: Presentation = serde_json::from_str(&j).unwrap();
assert_eq!(p, q);
// Unknown and missing fields are tolerated.
let q: Presentation = serde_json::from_str(r#"{"slides":[],"zzz":1}"#).unwrap();
assert!(q.slides.is_empty());
assert_eq!(q.slide_size, Size::new(960.0, 540.0));
// A side that is missing, null or not a number reads as NaN instead of failing the file.
let q: Presentation = serde_json::from_str(r#"{"slideSize":{"width":720,"height":null}}"#).unwrap();
assert_eq!(q.slide_size.width, 720.0);
assert!(q.slide_size.height.is_nan());
}
/// A deck whose ids are used up (a hostile file can arrive that way) yields no further ids:
/// allocating never repeats one.
#[test]
fn exhausted_id_space_allocates_nothing() {
let mut p = Presentation { next_id: u32::MAX, ..Default::default() };
assert_eq!(p.alloc_id(), None);
assert_eq!(p.alloc_id(), None);
assert!(p.add_media("x.png", "image/png", vec![9]).is_none(), "fresh media bytes are refused too");
}
#[test]
fn ids_are_unique_and_fix_next_id() {
let mut p = Presentation::default();
let shape = Shape { id: ShapeId(40), ..Default::default() };
p.slides.push(Arc::new(Slide { id: SlideId(30), layout: LayoutId(50), shapes: vec![shape], ..Default::default() }));
p.masters.push(Arc::new(Master { id: MasterId(7), layouts: vec![Layout { id: LayoutId(50), ..Default::default() }], ..Default::default() }));
p.fix_next_id();
assert_eq!(p.next_id, 50);
assert_eq!(p.alloc_id(), Some(51));
let m = p.add_media("a.png", "image/png", vec![1, 2]).unwrap();
assert_eq!(p.add_media("b.png", "image/png", vec![1, 2]), Some(m), "identical bytes are shared");
assert!(p.validate().is_empty(), "{:?}", p.validate());
Arc::make_mut(&mut p.slides[0]).layout = LayoutId(99);
assert_eq!(p.validate(), ["slide 30 uses missing layout 99"]);
}
#[test]
fn group_find_and_walk() {
let child = Shape { id: ShapeId(900), ..Default::default() };
let g = Shape { id: ShapeId(901), kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 1.0, 1.0) }, ..Default::default() };
let mut slide = Slide { shapes: vec![g], ..Default::default() };
assert!(slide.shape(ShapeId(900)).is_some());
assert!(slide.shape_mut(ShapeId(900)).is_some());
let mut n = 0;
walk(&slide.shapes, &mut |_, _| n += 1);
assert_eq!(n, 2);
}
#[test]
fn walks_stop_at_depth_64() {
let mut s = Shape { id: ShapeId(1), ..Default::default() };
for i in 2..100 {
s = Shape { id: ShapeId(i), kind: ShapeKind::Group { children: vec![s], child: Xfrm::default() }, ..Default::default() };
}
let tree = vec![s];
let mut n = 0;
walk(&tree, &mut |_, _| n += 1);
assert_eq!(n, 65);
assert!(find_shape(&tree, ShapeId(1)).is_none());
assert!(find_shape(&tree, ShapeId(40)).is_some());
}
#[test]
fn sibling_lists_and_parents_reach_into_groups() {
let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() };
let group = |id: u32, children: Vec<Shape>| Shape {
id: ShapeId(id),
kind: ShapeKind::Group { children, child: Default::default() },
..Default::default()
};
let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)];
assert_eq!(parent_of(&tree, ShapeId(1)), Some(None));
assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2))));
assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4))));
assert_eq!(parent_of(&tree, ShapeId(9)), None);
let ids = |v: &Vec<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6]));
assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4]));
assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5]));
assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none());
}
#[test]
fn ink_width_follows_pressure_and_keeps_the_old_placeholder() {
assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)]));
assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)]));
assert!(!ink_pressure_is_legacy(&[]));
assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9);
assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9);
assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9);
let floor = ink_sample_width(8.0, 0.0, false);
assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}");
assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9);
}
#[test]
fn colour_maps_alias_the_background_and_text_slots() {
let mut m = Master::default();
let base = m.theme.colors.clone();
assert_eq!(m.map_slot(SchemeSlot::Bg1), SchemeSlot::Lt1, "the default mapping when empty");
assert_eq!(m.map_slot(SchemeSlot::Accent2), SchemeSlot::Accent2);
let dark: Vec<(String, String)> = [("bg1", "dk1"), ("tx1", "lt1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
let s = m.scheme_with(&dark);
assert_eq!(s.get(SchemeSlot::Lt1), base.get(SchemeSlot::Dk1));
assert_eq!(s.get(SchemeSlot::Dk1), base.get(SchemeSlot::Lt1));
assert_eq!(s.get(SchemeSlot::Lt2), base.get(SchemeSlot::Lt2), "bg2 keeps its default mapping");
m.color_map = dark;
assert_eq!(m.scheme(), s);
}
#[test]
fn enum_names_round_trip_through_their_xml() {
for k in [PhType::Title, PhType::CtrTitle, PhType::Table, PhType::Diagram, PhType::SlideImage, PhType::SlideNum] {
assert_eq!(PhType::from_xml(k.xml()), k);
}
assert_eq!(PhType::from_xml("zzz"), PhType::Body);
for k in [LayoutType::Title, LayoutType::Comparison, LayoutType::VerticalTitleAndText, LayoutType::Custom] {
assert_eq!(LayoutType::from_xml(k.xml()), k);
}
assert_eq!(LayoutType::from_xml("twoColTx"), LayoutType::TwoContent);
}
+3
View File
@@ -21,6 +21,9 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
encoding_rs = { workspace = true }
serde = { workspace = true, optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
craft-psd = { path = ".", features = ["testgen"] }
+274 -25
View File
@@ -1,4 +1,4 @@
//! Photoshop Actions files (`.atn`): one action set, its actions and their steps.
//! Photoshop Actions files (`.atn`): action sets, their actions and their steps.
//!
//! Implemented clean-room from Adobe's "Photoshop File Formats" specification (the *Actions*
//! and *Descriptor structure* sections) and observation of files Photoshop writes. No code was
@@ -6,7 +6,8 @@
//! code-unit count + UTF-16BE, usually NUL-terminated) unless noted.
//!
//! ```text
//! file := version u32 (12 or 16), set name UnicodeString, expanded u8, count u32, count × action
//! file := version u32 (12 or 16), set (version 16 may hold further sets until EOF)
//! set := name UnicodeString, expanded u8, count u32, count × action
//! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16,
//! name UnicodeString, expanded u8, count u32, count × item
//! item := expanded u8, enabled u8, with dialog u8, dialog options u8,
@@ -31,8 +32,9 @@
//!
//! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor
//! nesting is bounded, and allocations are capped. [`write`] always produces version 16, so
//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for version 16 files
//! Photoshop wrote; a version 12 file's legacy descriptors keep their meaning, not their layout.
//! `parse(write(f)) == f` for sets [`parse`] read. The existing single-set API retains trailing-byte
//! warnings. [`parse_sets`] and [`write_sets`] preserve complete version-16 multi-set files with
//! one version header; a version-12 file's legacy descriptors keep their meaning, not their layout.
use crate::codepage;
use crate::descriptor::{Descriptor, UnicodeString};
@@ -40,6 +42,9 @@ use crate::error::{PsdError, Result};
use crate::io::{Reader, WriteExt, decode_legacy_name};
mod legacy;
mod sets;
pub use sets::{MAX_SETS, MAX_TOTAL_ACTIONS, MAX_TOTAL_ITEMS, parse_sets, parse_sets_with_encoding, parse_sets_with_spans, write_sets};
/// The version written by this module (Photoshop 6 and every later release).
pub const VERSION: u32 = 16;
@@ -48,6 +53,70 @@ pub const MAX_ACTIONS: usize = 10_000;
/// Most steps read from one action.
pub const MAX_ITEMS: usize = 100_000;
/// A complete version16 archive and byte provenance into its original input.
/// Ranges address the caller's input, including original names/flags/descriptor encodings.
/// No source bytes are copied or rewritten by this parser.
#[derive(Debug, Clone, PartialEq)]
pub struct SpannedAtnArchive {
/// Parsed sets in physical file order.
pub sets: Vec<AtnFile>,
/// Byte spans for the sets, in the same order.
pub spans: Vec<AtnSetSpan>,
}
/// One physical set, including its original header fields and action occurrences.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AtnSetSpan {
/// Half-open input range of the complete set, including its actions.
pub range: std::ops::Range<usize>,
/// Set header through the action count, before the first action.
pub header: std::ops::Range<usize>,
/// Length-prefixed Unicode set name bytes.
pub name: std::ops::Range<usize>,
/// Original expanded-flag byte.
pub expanded: std::ops::Range<usize>,
/// Original four-byte action count.
pub count: std::ops::Range<usize>,
/// Action spans in physical file order.
pub actions: Vec<AtnActionSpan>,
}
/// One physical action. Header ranges let a library edit one field without normalizing others.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AtnActionSpan {
/// Half-open input range of the complete action, including its steps.
pub range: std::ops::Range<usize>,
/// Action header through the step count, before the first step.
pub header: std::ops::Range<usize>,
/// Original function-key code bytes.
pub function_key: std::ops::Range<usize>,
/// Original Shift-modifier flag byte.
pub shift: std::ops::Range<usize>,
/// Original command-modifier flag byte.
pub command: std::ops::Range<usize>,
/// Original color-label code bytes.
pub color: std::ops::Range<usize>,
/// Length-prefixed Unicode action name bytes.
pub name: std::ops::Range<usize>,
/// Original expanded-flag byte.
pub expanded: std::ops::Range<usize>,
/// Original four-byte step count.
pub count: std::ops::Range<usize>,
/// Step spans in physical file order.
pub items: Vec<AtnItemSpan>,
}
/// One original event, even when it maps to several native command steps.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AtnItemSpan {
/// Half-open input range of the complete original step.
pub range: std::ops::Range<usize>,
/// Expanded, enabled, dialog, dialog-options bytes, in that order.
pub flags: std::ops::Range<usize>,
/// Descriptor body after its -1 flag; absent for a descriptor-free event.
pub descriptor: Option<std::ops::Range<usize>>,
}
/// A parsed action set.
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -217,7 +286,8 @@ fn write_ascii(out: &mut Vec<u8>, s: &[u8]) -> Result<()> {
}
/// Read one step with the descriptor dialect selected by the file version.
fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result<AtnItem> {
fn read_item(r: &mut Reader<'_>, legacy: bool, mut span: Option<&mut AtnItemSpan>) -> Result<AtnItem> {
let start = r.pos();
let expanded = r.u8()? != 0;
let enabled = r.u8()? != 0;
let with_dialog = r.u8()? != 0;
@@ -230,29 +300,67 @@ fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result<AtnItem> {
let dictionary = read_ascii(r)?;
let descriptor = match r.i32()? {
0 => None,
-1 => Some(if legacy { legacy::read(r)? } else { Descriptor::read(r)? }),
-1 => {
let start = r.pos();
let descriptor = if legacy { legacy::read(r)? } else { Descriptor::read(r)? };
if let Some(span) = span.as_deref_mut() {
span.descriptor = Some(start..r.pos());
}
Some(descriptor)
}
n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))),
};
if let Some(span) = span {
span.range = start..r.pos();
span.flags = start..start.saturating_add(4);
}
Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor })
}
/// Read an action and its steps using the file's descriptor dialect.
fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result<AtnAction> {
fn read_action(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>, mut span: Option<&mut AtnActionSpan>) -> Result<AtnAction> {
let start = r.pos();
let function_key = r.u16()?;
let shift_start = r.pos();
let shift = r.u8()? != 0;
let command_start = r.pos();
let command = r.u8()? != 0;
let color_start = r.pos();
let color = r.u16()?;
let name_start = r.pos();
let name = UnicodeString::read(r)?;
let expanded_start = r.pos();
let expanded = r.u8()? != 0;
let count_start = r.pos();
let n = r.u32()?;
if let Some(span) = span.as_deref_mut() {
span.header = start..r.pos();
span.function_key = start..shift_start;
span.shift = shift_start..command_start;
span.command = command_start..color_start;
span.color = color_start..name_start;
span.name = name_start..expanded_start;
span.expanded = expanded_start..count_start;
span.count = count_start..r.pos();
}
// Each item needs at least its 4 flags, the event kind, two empty names and the descriptor flag.
r.check_count(u64::from(n), 20)?;
if n as usize > MAX_ITEMS {
return Err(PsdError::LimitExceeded("too many steps in one action"));
}
if let Some(budget) = budget {
budget.items(n as usize)?;
}
let mut items = Vec::with_capacity((n as usize).min(1024));
for _ in 0..n {
items.push(read_item(r, legacy)?);
let mut item_span = span.as_ref().map(|_| AtnItemSpan::default());
items.push(read_item(r, legacy, item_span.as_mut())?);
if let (Some(span), Some(item)) = (span.as_deref_mut(), item_span) {
span.items.push(item);
}
}
if let Some(span) = span {
span.range = start..r.pos();
}
Ok(AtnAction { function_key, shift, command, color, name, expanded, items })
}
@@ -278,33 +386,74 @@ pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result<AtnFil
fn parse_layout(data: &[u8]) -> Result<AtnFile> {
let mut r = Reader::new(data);
let version = read_version(&mut r)?;
let mut file = read_set(&mut r, version == 12, None)?;
if !r.is_empty() {
file.warnings.push(format!("{} trailing bytes ignored", r.remaining()));
}
Ok(file)
}
fn read_version(r: &mut Reader<'_>) -> Result<u32> {
let version = r.u32()?;
if !matches!(version, 12 | VERSION) {
return Err(PsdError::Unsupported(format!("actions file version {version} (supported versions: 12 and 16)")));
}
let name = UnicodeString::read(&mut r)?;
Ok(version)
}
fn read_set(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>) -> Result<AtnFile> {
read_set_with_spans(r, legacy, budget, None)
}
fn read_set_with_spans(
r: &mut Reader<'_>,
legacy: bool,
mut budget: Option<&mut sets::Budget>,
mut span: Option<&mut AtnSetSpan>,
) -> Result<AtnFile> {
let start = r.pos();
let name = UnicodeString::read(r)?;
let expanded_start = r.pos();
let expanded = r.u8()? != 0;
let count_start = r.pos();
let n = r.u32()?;
if let Some(span) = span.as_deref_mut() {
span.header = start..r.pos();
span.name = start..expanded_start;
span.expanded = expanded_start..count_start;
span.count = count_start..r.pos();
}
// Each action needs at least its key, modifiers, colour, an empty name, the flag and a count.
r.check_count(u64::from(n), 15)?;
if n as usize > MAX_ACTIONS {
return Err(PsdError::LimitExceeded("too many actions in one set"));
}
if let Some(budget) = budget.as_deref_mut() {
budget.actions(n as usize)?;
}
let mut actions = Vec::with_capacity((n as usize).min(256));
for _ in 0..n {
actions.push(read_action(&mut r, version == 12)?);
let mut action_span = span.as_ref().map(|_| AtnActionSpan::default());
actions.push(read_action(r, legacy, budget.as_deref_mut(), action_span.as_mut())?);
if let (Some(span), Some(action)) = (span.as_deref_mut(), action_span) {
span.actions.push(action);
}
}
let mut warnings = Vec::new();
if !r.is_empty() {
warnings.push(format!("{} trailing bytes ignored", r.remaining()));
if let Some(span) = span {
span.range = start..r.pos();
}
Ok(AtnFile { name, expanded, actions, warnings, encoding: None })
Ok(AtnFile { name, expanded, actions, warnings: Vec::new(), encoding: None })
}
/// The encoding of `f`'s 8-bit names that are not ASCII or UTF-8, or `None` if no name shown
/// from them needs one; adds a warning when the guess is uncertain.
fn name_encoding(f: &AtnFile, warnings: &mut Vec<String>) -> Option<&'static encoding_rs::Encoding> {
let items = || f.actions.iter().flat_map(|a| &a.items);
name_encoding_sets(std::slice::from_ref(f), warnings)
}
fn name_encoding_sets(files: &[AtnFile], warnings: &mut Vec<String>) -> Option<&'static encoding_rs::Encoding> {
let items = || files.iter().flat_map(|file| &file.actions).flat_map(|action| &action.items);
let legacy = |b: &[u8]| !b.is_ascii();
// Steps that store their name as Unicode too: the encoding that turns one into the other.
let pairs: Vec<(&[u8], String)> = items()
@@ -333,13 +482,26 @@ fn name_encoding(f: &AtnFile, warnings: &mut Vec<String>) -> Option<&'static enc
return None;
}
// The set, action and class names are UTF-16: their script points at the writer's locale.
let mut hint = f.name.to_string_lossy();
for name in f.actions.iter().map(|a| a.name.to_string_lossy()).chain(items().filter_map(class_name)) {
if hint.len() > codepage::GUESS_SAMPLE {
break;
let mut hint = String::new();
for (index, file) in files.iter().enumerate() {
if index == 0 {
hint = file.name.to_string_lossy();
} else if hint.len() <= codepage::GUESS_SAMPLE {
hint.push('\n');
hint.push_str(&file.name.to_string_lossy());
}
for name in file
.actions
.iter()
.map(|action| action.name.to_string_lossy())
.chain(file.actions.iter().flat_map(|action| &action.items).filter_map(class_name))
{
if hint.len() > codepage::GUESS_SAMPLE {
break;
}
hint.push('\n');
hint.push_str(&name);
}
hint.push('\n');
hint.push_str(&name);
}
let guess = codepage::guess(&orphans, &hint);
if guess.uncertain {
@@ -385,7 +547,12 @@ fn count(n: usize, what: &'static str) -> Result<u32> {
pub fn write(f: &AtnFile) -> Result<Vec<u8>> {
let mut out = Vec::new();
out.put_u32(VERSION);
f.name.write(&mut out);
write_set(&mut out, f)?;
Ok(out)
}
fn write_set(out: &mut Vec<u8>, f: &AtnFile) -> Result<()> {
f.name.write(out);
out.put_u8(u8::from(f.expanded));
out.put_u32(count(f.actions.len(), "too many actions")?);
for a in &f.actions {
@@ -393,14 +560,14 @@ pub fn write(f: &AtnFile) -> Result<Vec<u8>> {
out.put_u8(u8::from(a.shift));
out.put_u8(u8::from(a.command));
out.put_u16(a.color);
a.name.write(&mut out);
a.name.write(out);
out.put_u8(u8::from(a.expanded));
out.put_u32(count(a.items.len(), "too many steps")?);
for item in &a.items {
write_item(&mut out, item)?;
write_item(out, item)?;
}
}
Ok(out)
Ok(())
}
#[cfg(test)]
@@ -863,4 +1030,86 @@ mod tests {
let _ = parse(&bytes);
}
}
/// Independent wire input: duplicate raw UTF16 names, noncanonical true bytes, and a
/// descriptor boolean whose semantic value cannot preserve its original byte by itself.
fn spanned_wire_archive() -> Vec<u8> {
fn ascii(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
let mut out = 16u32.to_be_bytes().to_vec();
let raw_name = [0, 0, 0, 2, 0xd8, 0, 0, 0]; // unpaired high surrogate, then NUL
for _ in 0..2 {
out.extend_from_slice(&raw_name);
out.push(2); // expanded=true, retaining its original byte matters
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&[0, 3, 2, 0, 0, 12]); // F3, Shift byte2, no Command, colour12
out.extend_from_slice(&raw_name);
out.push(2);
out.extend_from_slice(&2u32.to_be_bytes());
out.extend_from_slice(&[2, 2, 0, 3]);
out.extend_from_slice(b"longFltn");
ascii(&mut out, b"Flatten");
out.extend_from_slice(&0i32.to_be_bytes());
out.extend_from_slice(&[0, 1, 2, 2]);
out.extend_from_slice(b"TEXT");
ascii(&mut out, b"futureAction");
ascii(&mut out, b"Future");
out.extend_from_slice(&(-1i32).to_be_bytes());
out.extend_from_slice(&0u32.to_be_bytes()); // empty descriptor Unicode name
out.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(b"null");
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(b"flagbool");
out.push(2);
}
out
}
#[test]
fn archive_spans_retain_physical_occurrences_and_original_wire_fields() {
let bytes = spanned_wire_archive();
let archive = parse_sets_with_spans(&bytes).unwrap();
assert_eq!(archive.sets.len(), 2);
assert_eq!(archive.spans.len(), 2);
assert_eq!(archive.spans[0].range.start, 4);
assert_eq!(archive.spans[0].range.end, archive.spans[1].range.start);
assert_eq!(archive.spans[1].range.end, bytes.len());
let mut untouched = bytes[..4].to_vec();
for (file, set) in archive.sets.iter().zip(&archive.spans) {
assert_eq!(file.name.0, [0xd800, 0]);
assert_eq!(bytes[set.expanded.clone()], [2]);
assert_eq!(bytes[set.count.clone()], 1u32.to_be_bytes());
assert_eq!(set.header.end, set.actions[0].range.start);
let action = &set.actions[0];
assert_eq!(bytes[action.function_key.clone()], [0, 3]);
assert_eq!(bytes[action.shift.clone()], [2]);
assert_eq!(bytes[action.command.clone()], [0]);
assert_eq!(bytes[action.color.clone()], [0, 12]);
assert_eq!(bytes[action.expanded.clone()], [2]);
assert_eq!(bytes[action.count.clone()], 2u32.to_be_bytes());
assert_eq!(action.header.end, action.items[0].range.start);
assert_eq!(bytes[action.items[0].flags.clone()], [2, 2, 0, 3]);
assert_eq!(bytes[action.items[1].flags.clone()], [0, 1, 2, 2]);
assert_eq!(action.items[0].range.end, action.items[1].range.start);
assert_eq!(action.items[1].range.end, action.range.end);
assert_eq!(action.range.end, set.range.end);
assert_eq!(bytes[action.items[1].range.clone()].last(), Some(&2));
untouched.extend_from_slice(&bytes[set.range.clone()]);
}
assert_eq!(untouched, bytes, "no semantic writer generates the expected raw bytes");
assert_eq!(parse_sets(&bytes).unwrap(), archive.sets, "ordinary parsing keeps its semantic API");
assert_ne!(write_sets(&archive.sets).unwrap(), bytes, "typed boolean rewriting is not a raw library round trip");
}
#[test]
fn archive_spans_reject_partial_later_records_and_legacy_layout() {
let mut bytes = spanned_wire_archive();
bytes.pop(); // the second set's descriptor value is incomplete
assert!(parse_sets_with_spans(&bytes).is_err(), "no partial archive is admitted");
assert!(parse_sets_with_spans(&16u32.to_be_bytes()).is_err(), "a version prefix is not an invented empty ATN");
assert!(matches!(parse_sets_with_spans(&12u32.to_be_bytes()), Err(PsdError::Unsupported(_))));
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Whole-file version-16 set streams. The original single-set/legacy API remains compatible.
use super::{
AtnFile, AtnSetSpan, MAX_ACTIONS, MAX_ITEMS, SpannedAtnArchive, VERSION, codepage, name_encoding_sets, read_set_with_spans, read_version,
write_set,
};
use crate::{
error::{PsdError, Result},
io::{Reader, WriteExt},
};
/// Whole-archive resource ceilings, not Photoshop format maxima. Per-set/action ceilings still apply.
pub const MAX_SETS: usize = MAX_ACTIONS;
/// Maximum action headers across the complete archive, including any metadata sidecar.
pub const MAX_TOTAL_ACTIONS: usize = MAX_ACTIONS;
/// Maximum steps across the complete archive, including any metadata sidecar.
pub const MAX_TOTAL_ITEMS: usize = MAX_ITEMS;
#[derive(Default)]
pub(super) struct Budget {
actions: usize,
items: usize,
}
impl Budget {
pub(super) fn actions(&mut self, count: usize) -> Result<()> {
if count > MAX_ACTIONS {
return Err(PsdError::LimitExceeded("too many actions in one set"));
}
self.actions = self
.actions
.checked_add(count)
.filter(|sum| *sum <= MAX_TOTAL_ACTIONS)
.ok_or(PsdError::LimitExceeded("too many actions in actions archive"))?;
Ok(())
}
pub(super) fn items(&mut self, count: usize) -> Result<()> {
if count > MAX_ITEMS {
return Err(PsdError::LimitExceeded("too many steps in one action"));
}
self.items = self
.items
.checked_add(count)
.filter(|sum| *sum <= MAX_TOTAL_ITEMS)
.ok_or(PsdError::LimitExceeded("too many steps in actions archive"))?;
Ok(())
}
}
/// Read every set in a version-16 file, in order, with one version prefix and strict EOF.
/// A malformed later set fails the whole result. Version12 retains the existing one-set/tail contract.
pub fn parse_sets(data: &[u8]) -> Result<Vec<AtnFile>> {
parse_sets_with_encoding(data, None)
}
/// [`parse_sets`] with one legacy name encoding for the complete file. Automatic detection also
/// uses the complete stream; its diagnostic is attached once to the first set.
pub fn parse_sets_with_encoding(data: &[u8], encoding: Option<&str>) -> Result<Vec<AtnFile>> {
parse_archive(data, encoding, false).map(|archive| archive.sets)
}
/// Read a strict complete version16 archive, retaining physical record/field ranges.
/// Legacy version12 conversion is deliberately excluded: its descriptor dialect cannot be
/// spliced unchanged into a version16 library. Existing import APIs still accept it.
/// A header without any real set remains invalid; an empty logical library needs a valid carrier.
pub fn parse_sets_with_spans(data: &[u8]) -> Result<SpannedAtnArchive> {
parse_archive(data, None, true)
}
fn parse_archive(data: &[u8], encoding: Option<&str>, capture: bool) -> Result<SpannedAtnArchive> {
let forced = encoding.map(codepage::resolve_label).transpose()?;
let mut reader = Reader::new(data);
let version = read_version(&mut reader)?;
let legacy = version == 12;
if capture && legacy {
return Err(PsdError::Unsupported("raw Actions library provenance requires version16".into()));
}
let mut files = Vec::new();
let mut spans = Vec::new();
let mut budget = Budget::default();
loop {
if files.len() >= MAX_SETS {
return Err(PsdError::LimitExceeded("too many sets in actions archive"));
}
let before = reader.remaining();
let mut span = capture.then(AtnSetSpan::default);
let mut file = read_set_with_spans(&mut reader, legacy, Some(&mut budget), span.as_mut())?;
if let Some(span) = span {
spans.push(span);
}
if legacy && !reader.is_empty() {
file.warnings.push(format!("{} trailing bytes ignored", reader.remaining()));
}
if reader.remaining() >= before {
return Err(PsdError::invalid("actions set made no progress"));
}
files.push(file);
if legacy || reader.is_empty() {
break;
}
}
let mut warnings = Vec::new();
let encoding = forced.or_else(|| name_encoding_sets(&files, &mut warnings)).map(|encoding| encoding.name().to_string());
for file in &mut files {
file.encoding = encoding.clone();
}
if let Some(first) = files.first_mut() {
first.warnings.extend(warnings);
}
Ok(SpannedAtnArchive { sets: files, spans })
}
/// Write all sets under one version16 prefix. Validate aggregate counts before allocating output.
/// At least one set is required; an empty set is valid. Stored names/descriptors stay verbatim.
pub fn write_sets(files: &[AtnFile]) -> Result<Vec<u8>> {
if files.is_empty() {
return Err(PsdError::invalid("actions archive must contain at least one set"));
}
if files.len() > MAX_SETS {
return Err(PsdError::LimitExceeded("too many sets in actions archive"));
}
let mut budget = Budget::default();
for file in files {
budget.actions(file.actions.len())?;
for action in &file.actions {
budget.items(action.items.len())?;
}
}
let mut out = Vec::new();
out.put_u32(VERSION);
for file in files {
write_set(&mut out, file)?;
}
Ok(out)
}
+230 -65
View File
@@ -68,34 +68,90 @@ pub mod packbits {
/// Encodes one row, appending to `out`. Never emits the `-128` no-op.
pub fn encode(src: &[u8], out: &mut Vec<u8>) {
// Constant 16-bit samples with different bytes contain only literal packets.
if src.len() >= 128
&& let Some(&[a, b]) = src.first_chunk::<2>()
&& a != b
{
let (blocks, tail) = src.as_chunks::<8>();
if blocks.iter().all(|v| *v == [a, b, a, b, a, b, a, b]) && tail.iter().zip([a, b].into_iter().cycle()).all(|(&v, p)| v == p) {
for chunk in src.chunks(128) {
out.push((chunk.len() - 1) as u8);
out.extend_from_slice(chunk);
}
return;
}
}
packets(src, |header, bytes| {
out.push(header);
out.extend_from_slice(bytes);
});
}
/// The encoded row length, without allocating or writing its payload.
pub fn encoded_len(src: &[u8]) -> usize {
let mut len = 0;
packets(src, |_, bytes| len += 1 + bytes.len());
len
}
/// Visit PackBits run and literal packets in their deterministic encoding order.
fn packets(src: &[u8], mut emit: impl FnMut(u8, &[u8])) {
let n = src.len();
let mut i = 0;
while i < n {
// Length of the run starting at i.
let mut run = 1;
while i + run < n && run < 128 && src[i + run] == src[i] {
while i + run < n && run < 8 && src[i + run] == src[i] {
run += 1;
}
if run == 8 {
while run + 8 <= 128 && src.get(i + run..).and_then(|s| s.first_chunk::<8>()) == Some(&[src[i]; 8]) {
run += 8;
}
while i + run < n && run < 128 && src[i + run] == src[i] {
run += 1;
}
}
// A literal never stops before a 2-byte run, so any run of >= 2
// seen here starts a packet boundary: a run packet is never larger.
if run >= 2 {
out.push((1i16 - run as i16) as i8 as u8);
out.push(src[i]);
emit((1i16 - run as i16) as i8 as u8, &src[i..i + 1]);
i += run;
continue;
}
// Literal: extend until a run of >= 3 begins or 128 bytes.
let start = i;
let mut len = 0;
while i < n && len < 128 {
// Short packets keep the scalar scan; lookahead pays only on longer literals.
while i < n && len < 16 {
if i + 2 < n && src[i] == src[i + 1] && src[i] == src[i + 2] {
break;
}
i += 1;
len += 1;
}
out.push((len - 1) as u8);
out.extend_from_slice(&src[start..start + len]);
if len == 16 {
while len + 8 <= 128 {
let Some(&[a, b, c, d, e, f, g, h, j, k]) = src.get(i..).and_then(|s| s.first_chunk::<10>()) else { break };
let word = u64::from_le_bytes([a, b, c, d, e, f, g, h]);
let different = (word ^ u64::from_le_bytes([b, c, d, e, f, g, h, j])) | (word ^ u64::from_le_bytes([c, d, e, f, g, h, j, k]));
// A zero byte marks a triple; scalar scanning keeps the first boundary.
if different.wrapping_sub(0x0101010101010101) & !different & 0x8080808080808080 != 0 {
break;
}
i += 8;
len += 8;
}
while i < n && len < 128 {
if i + 2 < n && src[i] == src[i + 1] && src[i] == src[i + 2] {
break;
}
i += 1;
len += 1;
}
}
emit((len - 1) as u8, &src[start..start + len]);
}
}
@@ -369,82 +425,178 @@ fn decode_rle(data: &[u8], layout: &PlaneLayout, total: usize) -> Result<Vec<u8>
Ok(out)
}
/// Encode independent row chunks and join their count tables and payloads in order.
fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result<Vec<u8>> {
let rows = layout.rows()?;
let rb = layout.row_bytes();
let cs = layout.count_size();
if rows == 0 {
return Ok(Vec::new());
}
// Rows are independent, so they pack in parallel chunks of rows. Two passes: the first only
// measures each packed row, so the output is allocated once at its exact size and the second
// packs every row straight into its place. Collecting packed chunks and then copying them into
// the output held the encoded image twice, with growth slack, at the save's peak (photocraft#6607).
let rb = layout.row_bytes();
let cs = layout.count_size();
let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) };
let chunk_rows = rows.div_ceil(threads).max(1);
let row = |r: usize| &decoded[r * rb..(r + 1) * rb];
let mut lens = vec![0usize; rows];
for_each_chunk(threads, lens.chunks_mut(chunk_rows).enumerate().collect(), |(c, lens): (usize, &mut [usize])| {
let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128));
for (i, len) in lens.iter_mut().enumerate() {
scratch.clear();
packbits::encode(row(c * chunk_rows + i), &mut scratch);
*len = scratch.len();
}
})?;
let mut out = vec![0u8; rows * cs + lens.iter().sum::<usize>()];
let (counts, mut packed) = out.split_at_mut(rows * cs);
for (count, &n) in counts.chunks_exact_mut(cs).zip(&lens) {
if cs == 2 {
let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?;
count.copy_from_slice(&n.to_be_bytes());
} else {
let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?;
count.copy_from_slice(&n.to_be_bytes());
}
let step = rows.div_ceil(threads).max(1);
let ranges: Vec<_> = (0..rows).step_by(step).map(|start| start..start.saturating_add(step).min(rows)).collect();
// Scan packets once. Each worker owns its count table and payload; joining copies each
// payload once, trading temporary encoded bytes for the former second packet scan.
let mut chunks: Vec<_> = map_chunks(threads, ranges.clone(), |range| -> Result<Vec<u8>> {
let table = range.len().checked_mul(cs).ok_or(PsdError::LimitExceeded("RLE table overflow"))?;
let hint =
if cfg!(target_arch = "wasm32") { table } else { table.saturating_add(range.len().saturating_mul(rb.saturating_add(rb.div_ceil(128)))) };
encode_rle_chunk(decoded, layout, range, hint)
})?
.into_iter()
.collect::<Result<_>>()?;
if chunks.len() == 1 {
return Ok(chunks.remove(0).into_boxed_slice().into_vec());
}
let mut parts = Vec::with_capacity(threads);
for (c, lens) in lens.chunks(chunk_rows).enumerate() {
let (part, rest) = packed.split_at_mut(lens.iter().sum());
parts.push((c, lens, part));
packed = rest;
let total = chunks.iter().try_fold(0usize, |n, c| n.checked_add(c.len()).ok_or(PsdError::LimitExceeded("RLE size overflow")))?;
let table = rows.checked_mul(cs).ok_or(PsdError::LimitExceeded("RLE table overflow"))?;
let mut out = Vec::new();
out.try_reserve_exact(total).map_err(|_| PsdError::OutOfMemory("not enough memory for encoded RLE"))?;
out.resize(table, 0);
for (range, chunk) in ranges.into_iter().zip(chunks) {
let counts = range.len() * cs;
out[range.start * cs..range.end * cs].copy_from_slice(&chunk[..counts]);
out.extend_from_slice(&chunk[counts..]);
}
for_each_chunk(threads, parts, |(c, lens, mut part): (usize, &[usize], &mut [u8])| {
let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128));
for (i, &n) in lens.iter().enumerate() {
scratch.clear();
packbits::encode(row(c * chunk_rows + i), &mut scratch);
let (dst, rest) = std::mem::take(&mut part).split_at_mut(n);
dst.copy_from_slice(&scratch);
part = rest;
}
})?;
Ok(out)
}
/// Runs `f` on every item, each on its own thread unless `threads` is 1 (wasm32).
fn for_each_chunk<T: Send>(threads: usize, items: Vec<T>, f: impl Fn(T) + Sync) -> Result<()> {
if threads == 1 {
items.into_iter().for_each(f);
return Ok(());
}
std::thread::scope(|scope| {
let handles: Vec<_> = items.into_iter().map(|item| scope.spawn(|| f(item))).collect();
// Join every worker instead of aborting the scope with a thread panic.
let mut ok = true;
for h in handles {
ok &= h.join().is_ok();
/// Encode one worker's rows, measuring packets only when the spare capacity cannot bound them.
fn encode_rle_chunk(decoded: &[u8], layout: &PlaneLayout, range: std::ops::Range<usize>, hint: usize) -> Result<Vec<u8>> {
const OOM: PsdError = PsdError::OutOfMemory("not enough memory for encoded RLE");
let rb = layout.row_bytes();
let cs = layout.count_size();
let table = range.len().checked_mul(cs).ok_or(PsdError::LimitExceeded("RLE table overflow"))?;
let mut out = Vec::new();
out.try_reserve_exact(hint.max(table)).or_else(|_| out.try_reserve_exact(table)).map_err(|_| OOM)?;
out.resize(table, 0);
let row_bound = rb.saturating_add(rb.div_ceil(128));
for (i, row) in range.enumerate() {
let src = &decoded[row * rb..(row + 1) * rb];
if out.capacity() - out.len() < row_bound {
let needed = packbits::encoded_len(src);
out.try_reserve(needed).or_else(|_| out.try_reserve_exact(needed)).map_err(|_| OOM)?;
}
if ok { Ok(()) } else { Err(PsdError::LimitExceeded("an RLE worker panicked")) }
})
let start = out.len();
packbits::encode(src, &mut out);
let n = out.len() - start;
let count = &mut out[i * cs..(i + 1) * cs];
if cs == 2 {
count.copy_from_slice(&u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?.to_be_bytes());
} else {
count.copy_from_slice(&u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?.to_be_bytes());
}
}
Ok(out)
}
/// Ordered work on the caller's pool, including nested channel and row encoding.
fn map_chunks<T: Send, R: Send>(threads: usize, items: Vec<T>, f: impl Fn(T) -> R + Sync) -> Result<Vec<R>> {
if threads == 1 {
return Ok(items.into_iter().map(f).collect());
}
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| items.into_par_iter().map(&f).collect()))
.map_err(|_| PsdError::LimitExceeded("an RLE worker panicked"))
}
#[cfg(target_arch = "wasm32")]
Ok(items.into_iter().map(f).collect())
}
/// zlib-compresses `data` at the default level.
pub fn zip_compress(data: &[u8]) -> Vec<u8> {
#[cfg(not(target_arch = "wasm32"))]
let mut e = zip_encoder();
#[cfg(target_arch = "wasm32")]
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
// Writing into a Vec cannot fail.
// The in-memory output always accepts the complete write.
let _ = e.write_all(data);
e.finish().unwrap_or_default()
#[cfg(not(target_arch = "wasm32"))]
{
if e.try_finish().is_err() {
return Vec::new();
}
let output = std::mem::take(e.get_mut());
// Return only the finished codec; the slot stays empty during compression.
let _ = ZIP_ENCODER.try_with(|slot| slot.set(Some(e)));
output.into_vec()
}
#[cfg(target_arch = "wasm32")]
{
e.finish().unwrap_or_default()
}
}
#[cfg(not(target_arch = "wasm32"))]
thread_local! {
/// One finished codec per thread; encoded output moves out before it is retained.
static ZIP_ENCODER: std::cell::Cell<Option<flate2::write::ZlibEncoder<ZipOutput>>> = const { std::cell::Cell::new(None) };
}
/// Reset a thread's finished encoder without reallocating its fixed-size working buffers.
/// Nested calls and thread teardown can use a fresh encoder without accessing a busy slot.
#[cfg(not(target_arch = "wasm32"))]
fn zip_encoder() -> flate2::write::ZlibEncoder<ZipOutput> {
if let Some(mut encoder) = ZIP_ENCODER.try_with(std::cell::Cell::take).ok().flatten()
&& encoder.reset(ZipOutput::default()).is_ok()
{
return encoder;
}
flate2::write::ZlibEncoder::new(ZipOutput::default(), flate2::Compression::default())
}
/// Bounded native output blocks avoid reallocating large ZIP buffers as they grow.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct ZipOutput {
blocks: Vec<Vec<u8>>,
current: Vec<u8>,
}
#[cfg(not(target_arch = "wasm32"))]
impl std::io::Write for ZipOutput {
/// Accept all input bytes into bounded output blocks without reserving raw image size.
fn write(&mut self, mut bytes: &[u8]) -> std::io::Result<usize> {
let len = bytes.len();
while !bytes.is_empty() {
if self.current.len() == self.current.capacity() {
if !self.current.is_empty() {
self.blocks.push(std::mem::take(&mut self.current));
}
self.current = Vec::with_capacity(if self.blocks.is_empty() { 8192 } else { 256 << 10 });
}
let part = &mut self.current;
let (head, rest) = bytes.split_at((part.capacity() - part.len()).min(bytes.len()));
part.extend_from_slice(head);
bytes = rest;
}
Ok(len)
}
/// Complete a no-op flush because every write is already retained in memory.
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
impl ZipOutput {
/// Join encoded ZIP blocks into one vector sized to the compressed payload.
fn into_vec(mut self) -> Vec<u8> {
self.blocks.push(self.current);
if self.blocks.len() == 1 {
return self.blocks.pop().unwrap_or_default().into_boxed_slice().into_vec();
}
let len = self.blocks.iter().fold(0usize, |n, c| n.saturating_add(c.len()));
let mut out = Vec::with_capacity(len);
for part in self.blocks {
out.extend_from_slice(&part);
}
out
}
}
/// Decompresses a zlib stream, requiring at least `expected` output bytes and
@@ -489,6 +641,20 @@ pub fn predict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> {
if rb == 0 {
return Ok(());
}
#[cfg(not(target_arch = "wasm32"))]
if buf.len() >= 1 << 20 && matches!(layout.depth, 1 | 8 | 16 | 32) {
use rayon::prelude::*;
let threads = rayon::current_num_threads().min(32);
let rows = buf.len() / rb;
let step = rows.div_ceil(threads).max(1) * rb;
return buf[..rows * rb].par_chunks_mut(step).try_for_each(|part| predict_rows(part, layout));
}
predict_rows(buf, layout)
}
/// Apply per-row prediction, reusing scratch for float byte-plane shuffling.
fn predict_rows(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> {
let rb = layout.row_bytes();
let w = layout.width;
let mut scratch = Vec::new();
for row in buf.chunks_exact_mut(rb) {
@@ -509,7 +675,6 @@ pub fn predict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> {
}
}
32 => {
scratch.clear();
scratch.resize(rb, 0);
// Split into four byte planes (MSB first) and delta the whole
// plane-major row. Plane k's first delta uses the last byte of
+292
View File
@@ -137,13 +137,84 @@ fn layout(planes: usize, width: usize, height: usize, depth: u16, version: Versi
PlaneLayout { planes, width, height, depth, version }
}
/// Verify PackBits round trips empty, literal and repeated-byte inputs.
fn pb_roundtrip(src: &[u8]) -> Vec<u8> {
let enc = packbits::encode_vec(src);
assert_eq!(packbits::encoded_len(src), enc.len());
let dec = packbits::decode(&enc, src.len()).unwrap();
assert_eq!(dec, src, "encoded: {enc:?}");
enc
}
/// Compare block scanning with scalar packet boundaries over varied runs and literals.
#[test]
fn packbits_block_scan_preserves_scalar_packets() {
/// Encode PackBits with the previous scalar packet scan as a byte oracle.
fn scalar(src: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < src.len() {
let mut run = 1;
while i + run < src.len() && run < 128 && src[i + run] == src[i] {
run += 1;
}
if run >= 2 {
out.extend_from_slice(&[(1i16 - run as i16) as u8, src[i]]);
i += run;
} else {
let start = i;
while i < src.len() && i - start < 128 {
if i + 2 < src.len() && src[i] == src[i + 1] && src[i] == src[i + 2] {
break;
}
i += 1;
}
out.push((i - start - 1) as u8);
out.extend_from_slice(&src[start..i]);
}
}
out
}
let check = |src: &[u8]| {
let expected = scalar(src);
let mut actual = vec![42];
packbits::encode(src, &mut actual);
assert_eq!(&actual[1..], expected, "source {src:?}");
assert_eq!(packbits::encoded_len(src), expected.len());
assert!(expected.len() <= src.len() + src.len().div_ceil(128));
};
for n in 0..=10 {
for mut bits in 0..3usize.pow(n) {
let src: Vec<_> = (0..n)
.map(|_| {
let b = [0, 1, 255][bits % 3];
bits /= 3;
b
})
.collect();
check(&src);
}
}
for start in 0..260 {
for run in [2, 3, 7, 8, 9, 127, 128, 129, 256] {
let mut src: Vec<_> = (0..520).map(|i| (i % 251) as u8).collect();
src[start..start + run].fill(255);
check(&src);
}
}
for pair in [[0, 1], [0, 255], [128, 127], [255, 255]] {
for n in [127, 128, 129, 255, 256, 257, 12000, 12001] {
let mut src: Vec<_> = pair.into_iter().cycle().take(n).collect();
check(&src);
for i in [0, 7, 8, 127.min(n - 1), 128.min(n - 1), n - 1] {
src[i] ^= 1;
check(&src);
src[i] ^= 1;
}
}
}
}
#[test]
fn zip_absurd_declared_size_is_an_error() {
let z = zip_compress(&[7u8; 64]);
@@ -452,6 +523,24 @@ fn prediction_32bit_floats_roundtrip() {
assert_eq!(decode_planes(Compression::ZipPrediction, &enc, &l).unwrap(), v);
}
/// Compare fused float prediction with explicit byte-plane shuffling and delta coding.
#[test]
fn fused_prediction_matches_shuffled_byte_deltas() {
for w in [1, 2, 3, 31, 256, 257] {
let l = layout(2, w, 3, 32, Version::Psb);
let mut actual: Vec<_> = (0..l.decoded_len().unwrap()).map(|i| (i * 31 + i / 7) as u8).collect();
let expected: Vec<_> = actual
.chunks_exact(w * 4)
.flat_map(|row| {
let shuffled: Vec<_> = (0..4).flat_map(|k| row.iter().skip(k).step_by(4).copied()).collect();
(0..shuffled.len()).map(move |i| shuffled[i].wrapping_sub(if i == 0 { 0 } else { shuffled[i - 1] }))
})
.collect();
predict(&mut actual, &l).unwrap();
assert_eq!(actual, expected, "width {w}");
}
}
#[test]
fn zip_bomb_is_bounded() {
let big = zip_compress(&vec![0u8; 1 << 20]);
@@ -463,3 +552,206 @@ fn zip_bomb_is_bounded() {
fn zip_garbage_errors() {
assert!(zip_decompress(&[1, 2, 3, 4], 4).is_err());
}
/// Compare bounded ZIP output with the original growing writer over varied inputs.
#[test]
fn zip_output_matches_the_growing_writer() {
let mut seed = 0x12345678u32;
let noise = (0..1_048_577)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed as u8
})
.collect();
for bytes in [Vec::new(), vec![0; 131072], (0..131072).map(|i| (i * 31 + i / 173) as u8).collect(), noise].iter().cycle().take(12) {
let mut old = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
old.write_all(bytes).unwrap();
let packed = zip_compress(bytes);
assert_eq!(packed, old.finish().unwrap());
#[cfg(not(target_arch = "wasm32"))]
assert_eq!(packed.capacity(), packed.len());
}
}
/// Workers retain only private codec state; a checked-out slot supports nested compression.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn zip_encoder_reuse_is_private_and_retains_no_output() {
std::thread::scope(|scope| {
let workers: Vec<_> = (0..2)
.map(|byte| {
scope.spawn(move || {
let mut seed = 0x12345678u32;
for len in [1_048_577usize, 32769, 0, 259, 258, 257, 1, 32767, 32768, 2, 262145] {
let bytes: Vec<_> = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
if byte == 0 { 0 } else { seed as u8 }
})
.collect();
let packed = zip_compress(&bytes);
if byte == 1 && len == 1_048_577 {
assert!(packed.len() > 256 << 10, "the retention oracle must exercise multiple output blocks");
}
let encoder = ZIP_ENCODER.with(std::cell::Cell::take).unwrap();
assert_eq!(encoder.total_in(), len as u64);
assert_eq!(encoder.total_out(), packed.len() as u64);
assert_eq!(encoder.get_ref().blocks.capacity(), 0);
assert_eq!(encoder.get_ref().current.capacity(), 0);
assert_eq!(zip_decompress(&zip_compress(b"nested"), 6).unwrap(), b"nested");
ZIP_ENCODER.with(|slot| slot.set(Some(encoder)));
let mut fresh = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
fresh.write_all(&bytes).unwrap();
assert_eq!(zip_compress(&bytes), fresh.finish().unwrap());
}
})
})
.collect();
for worker in workers {
worker.join().unwrap();
}
});
}
/// Compression stays byte-identical during teardown, including an unavailable native cache.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn zip_compression_survives_thread_local_teardown() {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
struct OnDrop(Arc<(AtomicBool, AtomicBool)>);
impl Drop for OnDrop {
/// Record whether teardown reached the destroyed cache and preserved fresh-stream bytes.
fn drop(&mut self) {
let unavailable = ZIP_ENCODER.try_with(|_| ()).is_err();
let mut fresh = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
fresh.write_all(b"teardown").unwrap();
let same = zip_compress(b"teardown") == fresh.finish().unwrap();
self.0.0.store(same, Ordering::SeqCst);
self.0.1.store(unavailable, Ordering::SeqCst);
}
}
thread_local! { static AFTER_CODEC: std::cell::Cell<Option<OnDrop>> = const { std::cell::Cell::new(None) }; }
let checked = Arc::new((AtomicBool::new(false), AtomicBool::new(false)));
let flag = checked.clone();
std::thread::spawn(move || {
AFTER_CODEC.with(|slot| slot.set(Some(OnDrop(flag))));
zip_compress(b"first");
})
.join()
.unwrap();
assert!(checked.0.load(Ordering::SeqCst), "teardown must preserve the fresh encoder's bytes");
// OS-key TLS may destroy keys in another order; native TLS runs these destructors in reverse.
#[cfg(any(target_vendor = "apple", target_os = "linux", target_env = "msvc"))]
assert!(checked.1.load(Ordering::SeqCst), "teardown must exercise the uncached fallback");
}
/// Verify multiple writes retain their bytes and respect ZIP output block bounds.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn zip_output_blocks_stay_bounded_across_writes() {
let bytes = vec![42; (1 << 20) + 1];
let mut output = ZipOutput::default();
output.write_all(&[]).unwrap();
for part in bytes.chunks(32767) {
output.write_all(part).unwrap();
}
assert!(output.blocks.iter().chain([&output.current]).all(|b| b.capacity() <= 256 << 10));
let packed = output.into_vec();
assert_eq!(packed, bytes);
assert_eq!(packed.capacity(), packed.len());
}
/// Verify compressible ZIP output retains capacity proportional to encoded size.
#[test]
fn compressible_zip_does_not_retain_a_raw_sized_allocation() {
let raw = vec![0; 1 << 20];
let packed = zip_compress(&raw);
assert!(packed.len() < 1 << 14);
assert!(packed.capacity() < 1 << 16);
assert_eq!(zip_decompress(&packed, raw.len()).unwrap(), raw);
}
/// Verify failed RLE allocation hints fall back to incremental growth with identical bytes.
#[test]
fn rle_failed_hint_and_incremental_growth_preserve_bytes() {
for version in [Version::Psd, Version::Psb] {
for width in [0, 1, 127, 128, 129, 1000] {
let l = layout(1, width, 9, 8, version);
for fill in [false, true] {
let bytes: Vec<_> = (0..l.decoded_len().unwrap()).map(|i| if fill { 0 } else { (i * 13 + i / 17) as u8 }).collect();
let expected = encode_planes(Compression::Rle, &bytes, &l).unwrap();
for hint in [usize::MAX, 0, l.rows().unwrap() * l.count_size()] {
assert_eq!(encode_rle_chunk(&bytes, &l, 0..l.rows().unwrap(), hint).unwrap(), expected);
}
}
}
}
}
/// Verify measured packet lengths match encoding over mixed runs and literals.
#[test]
fn counted_packbits_matches_mixed_runs_and_literals() {
for n in 0..520 {
let bytes: Vec<_> = (0..n).map(|i| if i % 23 < 9 { (i / 23) as u8 } else { (i * 31) as u8 }).collect();
assert_eq!(packbits::encoded_len(&bytes), packbits::encode_vec(&bytes).len());
}
}
/// Compare parallel prediction with independent serial rows at each supported depth.
#[test]
fn prediction_matches_independent_serial_rows() {
for depth in [1, 8, 16, 32] {
let layout = layout(4, if depth == 1 { 8193 } else { 257 }, 1031, depth, Version::Psb);
let rb = layout.row_bytes();
let bytes: Vec<_> = (0..layout.decoded_len().unwrap() + 3).map(|i| (i * 13 + i / 17) as u8).collect();
let mut expected = bytes.clone();
for row in expected.chunks_exact_mut(rb) {
let src = row.to_vec();
if depth == 16 {
for (i, p) in row.as_chunks_mut::<2>().0.iter_mut().enumerate().skip(1) {
let a = u16::from_be_bytes([src[i * 2], src[i * 2 + 1]]);
let b = u16::from_be_bytes([src[i * 2 - 2], src[i * 2 - 1]]);
*p = a.wrapping_sub(b).to_be_bytes();
}
} else {
let shuffled = if depth == 32 { (0..4).flat_map(|c| src.iter().skip(c).step_by(4).copied()).collect::<Vec<_>>() } else { src };
for (i, p) in row.iter_mut().enumerate() {
*p = shuffled[i].wrapping_sub(if i == 0 { 0 } else { shuffled[i - 1] });
}
}
}
#[cfg(not(target_arch = "wasm32"))]
for threads in [1, 2, 4] {
use rayon::prelude::*;
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
pool.install(|| {
(0..3).into_par_iter().for_each(|_| {
let mut got = bytes.clone();
predict(&mut got, &layout).unwrap();
assert_eq!(got, expected, "depth {depth}, pool {threads}");
})
});
}
let mut got = bytes;
predict(&mut got, &layout).unwrap();
assert_eq!(got, expected);
}
}
/// Verify RLE chunks use the caller's pool and propagate worker panics as errors.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn rle_chunks_use_the_callers_pool_and_report_panics() {
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
pool.install(|| {
map_chunks(8, (0..64).collect(), |_| assert!(matches!(rayon::current_thread_index(), Some(0 | 1)))).unwrap();
assert!(map_chunks(2, vec![0, 1], |_| panic!("worker")).is_err());
});
}
+61 -6
View File
@@ -189,6 +189,52 @@ impl FilterEffects {
let mut out = Vec::new();
out.put_u32(self.version);
for it in &self.items {
let section = out.begin_len(true);
write_pascal(&mut out, it.id.as_bytes(), 1);
out.put_u32(it.version);
let at = out.begin_len(true);
write_rect(&mut out, &it.rect);
out.put_u32(it.depth);
out.put_u32(it.max_channels);
for i in 0..it.max_channels as usize + 2 {
match it.slots.get(i).and_then(Option::as_ref) {
Some(p) => {
out.put_u32(1);
write_plane(&mut out, p);
}
None => out.put_u32(0),
}
}
// A 64-bit length never overflows.
let _ = out.end_len(at, true);
match &it.mask {
Some((r, p)) => {
out.put_u8(1);
write_rect(&mut out, r);
write_plane(&mut out, p);
}
None => out.put_u8(0),
}
let _ = out.end_len(section, true);
out.resize(out.len().next_multiple_of(4), 0);
}
if self.items.is_empty() {
// One empty section, so the block still parses.
out.put_u64(0);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Serialize filter-effect sections through staging buffers as a byte oracle.
fn staged_bytes(fx: &FilterEffects) -> Vec<u8> {
let mut out = Vec::new();
out.put_u32(fx.version);
for it in &fx.items {
let mut body = Vec::new();
write_pascal(&mut body, it.id.as_bytes(), 1);
body.put_u32(it.version);
@@ -219,17 +265,12 @@ impl FilterEffects {
out.put(&body);
out.resize(out.len().next_multiple_of(4), 0);
}
if self.items.is_empty() {
if fx.items.is_empty() {
// One empty section, so the block still parses.
out.put_u64(0);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(id: &str, mask: bool) -> FilterEffectsItem {
let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 };
@@ -240,6 +281,20 @@ mod tests {
FilterEffectsItem { id: id.into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) }
}
/// Compare direct filter-effect serialization with staged bytes and section lengths.
#[test]
fn direct_sections_match_staged_bytes() {
for version in [1, 2, 3] {
let mut large = item(&"é".repeat(140), true);
large.rect.top = -7;
large.slots[0].as_mut().unwrap().data = vec![73; 8193];
for items in [Vec::new(), vec![large, item("odd", false), item("four", true)]] {
let fx = FilterEffects { version, items };
assert_eq!(fx.to_bytes(), staged_bytes(&fx));
}
}
}
/// The unpadded body of `it`'s section.
fn section(it: &FilterEffectsItem) -> Vec<u8> {
let b = FilterEffects { version: 3, items: vec![it.clone()] }.to_bytes();
+35 -26
View File
@@ -25,6 +25,8 @@
use crate::compression::{Compression, MAX_DECODED_BYTES, PlaneLayout, decode_planes, encode_planes};
use crate::error::{PsdError, Result};
use crate::header::Version;
use crate::io::WriteExt;
use std::borrow::Cow;
/// Largest pattern edge accepted (Photoshop's own limit is far lower).
const MAX_EDGE: u32 = 30_000;
@@ -417,7 +419,11 @@ fn zeroed_pattern_plane(len: usize) -> Result<Vec<u8>> {
Ok(out)
}
/// Append one validated pattern, backpatching lengths in its final output buffer.
fn write_pattern(p: &PsdPattern, out: &mut Vec<u8>) -> Result<()> {
if !matches!(p.depth, 1 | 8 | 16 | 32) {
return Err(PsdError::Unsupported(format!("pattern depth {}", p.depth)));
}
if p.width > MAX_EDGE || p.height > MAX_EDGE || p.width > i16::MAX as u32 || p.height > i16::MAX as u32 {
return Err(PsdError::LimitExceeded("pattern size"));
}
@@ -444,46 +450,50 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec<u8>) -> Result<()> {
out.extend_from_slice(&pal);
}
let rect = [0i32, 0, p.height as i32, p.width as i32];
let mut body = Vec::new();
out.extend_from_slice(&3u32.to_be_bytes());
let at = out.begin_len(false);
for v in rect {
body.extend_from_slice(&v.to_be_bytes());
out.extend_from_slice(&v.to_be_bytes());
}
const SLOTS: u32 = 24;
body.extend_from_slice(&SLOTS.to_be_bytes());
out.extend_from_slice(&SLOTS.to_be_bytes());
let layout = PlaneLayout { planes: 1, width: p.width as usize, height: p.height as usize, depth: p.depth, version: Version::Psd };
let array = |plane: &[u8], body: &mut Vec<u8>| -> Result<()> {
let array = |plane: &[u8], out: &mut Vec<u8>| -> Result<()> {
let (comp, data) = if p.depth == 8 {
(1u8, encode_planes(Compression::Rle, plane, &layout)?)
(1u8, Cow::Owned(encode_planes(Compression::Rle, plane, &layout)?))
} else {
(0u8, encode_planes(Compression::Raw, plane, &layout)?)
let total = layout.decoded_len()?;
if plane.len() != total {
return Err(PsdError::invalid(format!("decoded buffer has {} bytes, layout requires {}", plane.len(), total)));
}
(0u8, Cow::Borrowed(plane))
};
body.extend_from_slice(&1u32.to_be_bytes());
body.extend_from_slice(&((23 + data.len()) as u32).to_be_bytes());
body.extend_from_slice(&u32::from(p.depth).to_be_bytes());
out.extend_from_slice(&1u32.to_be_bytes());
let len = u32::try_from(data.len()).ok().and_then(|n| n.checked_add(23)).ok_or(PsdError::LimitExceeded("pattern array length"))?;
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(&u32::from(p.depth).to_be_bytes());
for v in rect {
body.extend_from_slice(&v.to_be_bytes());
out.extend_from_slice(&v.to_be_bytes());
}
body.extend_from_slice(&p.depth.to_be_bytes());
body.push(comp);
body.extend_from_slice(&data);
out.extend_from_slice(&p.depth.to_be_bytes());
out.push(comp);
out.extend_from_slice(&data);
Ok(())
};
let nc = p.channels.len().min(SLOTS as usize);
for c in &p.channels[..nc] {
array(c, &mut body)?;
array(c, out)?;
}
for _ in nc..SLOTS as usize {
body.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(&0u32.to_be_bytes());
}
// User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (photocraft#8054).
body.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(&0u32.to_be_bytes());
match &p.alpha {
Some(a) => array(a, &mut body)?,
None => body.extend_from_slice(&0u32.to_be_bytes()),
Some(a) => array(a, out)?,
None => out.extend_from_slice(&0u32.to_be_bytes()),
}
out.extend_from_slice(&3u32.to_be_bytes());
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out.end_len(at, false)?;
Ok(())
}
@@ -578,11 +588,10 @@ fn pattern_has_user_mask(r: &mut Rd) -> Result<bool> {
pub fn write_pattern_block(patterns: &[PsdPattern]) -> Result<Vec<u8>> {
let mut out = Vec::new();
for p in patterns {
let mut one = Vec::new();
write_pattern(p, &mut one)?;
out.extend_from_slice(&(one.len() as u32).to_be_bytes());
let pad = (4 - one.len() % 4) % 4;
out.extend_from_slice(&one);
let at = out.begin_len(false);
write_pattern(p, &mut out)?;
out.end_len(at, false)?;
let pad = (4 - (out.len() - at - 4) % 4) % 4;
out.extend(std::iter::repeat_n(0u8, pad));
}
Ok(out)
+121
View File
@@ -17,6 +17,56 @@ fn pat(depth: u16, alpha: bool) -> PsdPattern {
}
}
/// Compare direct pattern serialization with staged bytes at supported depths.
#[test]
fn direct_writes_match_staged_bytes() {
for depth in [1, 8, 16, 32] {
for alpha in [false, true] {
let mut p = pat(depth, alpha);
if depth == 1 {
p.channels = vec![vec![0xA8; 3]; 3];
p.alpha = alpha.then(|| vec![0xF8; 3]);
}
p.id = "é".repeat(140);
let mut indexed = p.clone();
indexed.mode = 2;
indexed.channels.truncate(1);
indexed.palette = Some(vec![73; 17]);
let patterns = [p, indexed];
let mut block = Vec::new();
let mut file = b"8BPT\0\x01\0\0\0\x02".to_vec();
for p in &patterns {
let mut one = Vec::new();
staged_pattern(p, &mut one).unwrap();
block.extend_from_slice(&(one.len() as u32).to_be_bytes());
block.extend_from_slice(&one);
block.resize(block.len().next_multiple_of(4), 0);
file.extend_from_slice(&one);
}
assert_eq!(write_pattern_block(&patterns).unwrap(), block);
assert_eq!(write_pat_file(&patterns).unwrap(), file);
}
}
}
/// Verify borrowed raw pattern planes retain layout and length validation.
#[test]
fn raw_pattern_writes_still_validate_layout_and_plane_length() {
for depth in [1, 16, 32, 7] {
let p = pat(depth, false);
assert!(write_pattern_block(&[PsdPattern { channels: vec![vec![0; 2]], ..p.clone() }]).is_err());
assert!(write_pat_file(&[PsdPattern { channels: Vec::new(), alpha: Some(vec![0; 2]), ..p }]).is_err());
}
}
/// Verify matching plane lengths cannot bypass unsupported pattern-depth checks.
#[test]
fn writers_reject_unsupported_depth_with_matching_plane_lengths() {
let p = PsdPattern { depth: 7, channels: vec![vec![0; 15]; 3], alpha: Some(vec![0; 15]), ..pat(8, false) };
assert!(write_pattern_block(std::slice::from_ref(&p)).is_err());
assert!(write_pat_file(&[p]).is_err());
}
/// photocraft#9318 review: a name the reader refuses (more than 65,536 UTF-16 units with its NUL) is
/// refused on write, so a saved library can't become a file that loads nothing.
#[test]
@@ -350,3 +400,74 @@ fn zero_size_pattern_rectangles_are_rejected() {
set_i32(&mut block, vma + 8, 0);
assert!(parse_pattern_block(&block).is_err());
}
/// Serialize a pattern with the previous staged writer as a byte oracle.
fn staged_pattern(p: &PsdPattern, out: &mut Vec<u8>) -> Result<()> {
if p.width > MAX_EDGE || p.height > MAX_EDGE || p.width > i16::MAX as u32 || p.height > i16::MAX as u32 {
return Err(PsdError::LimitExceeded("pattern size"));
}
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&p.mode.to_be_bytes());
out.extend_from_slice(&(p.height as u16).to_be_bytes());
out.extend_from_slice(&(p.width as u16).to_be_bytes());
let units: Vec<u16> = p.name.encode_utf16().chain(std::iter::once(0)).collect();
// The reader refuses longer names (see `read_pattern`).
if units.len() > 65_536 {
return Err(PsdError::LimitExceeded("pattern name length"));
}
out.extend_from_slice(&(units.len() as u32).to_be_bytes());
for u in units {
out.extend_from_slice(&u.to_be_bytes());
}
let id = p.id.as_bytes();
let idl = id.len().min(255);
out.push(idl as u8);
out.extend_from_slice(&id[..idl]);
if p.mode == 2 {
let mut pal = p.palette.clone().unwrap_or_default();
pal.resize(768, 0);
out.extend_from_slice(&pal);
}
let rect = [0i32, 0, p.height as i32, p.width as i32];
let mut body = Vec::new();
for v in rect {
body.extend_from_slice(&v.to_be_bytes());
}
const SLOTS: u32 = 24;
body.extend_from_slice(&SLOTS.to_be_bytes());
let layout = PlaneLayout { planes: 1, width: p.width as usize, height: p.height as usize, depth: p.depth, version: Version::Psd };
let array = |plane: &[u8], body: &mut Vec<u8>| -> Result<()> {
let (comp, data) = if p.depth == 8 {
(1u8, encode_planes(Compression::Rle, plane, &layout)?)
} else {
(0u8, encode_planes(Compression::Raw, plane, &layout)?)
};
body.extend_from_slice(&1u32.to_be_bytes());
body.extend_from_slice(&((23 + data.len()) as u32).to_be_bytes());
body.extend_from_slice(&u32::from(p.depth).to_be_bytes());
for v in rect {
body.extend_from_slice(&v.to_be_bytes());
}
body.extend_from_slice(&p.depth.to_be_bytes());
body.push(comp);
body.extend_from_slice(&data);
Ok(())
};
let nc = p.channels.len().min(SLOTS as usize);
for c in &p.channels[..nc] {
array(c, &mut body)?;
}
for _ in nc..SLOTS as usize {
body.extend_from_slice(&0u32.to_be_bytes());
}
// User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (photocraft#8054).
body.extend_from_slice(&0u32.to_be_bytes());
match &p.alpha {
Some(a) => array(a, &mut body)?,
None => body.extend_from_slice(&0u32.to_be_bytes()),
}
out.extend_from_slice(&3u32.to_be_bytes());
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
Ok(())
}
+233
View File
@@ -0,0 +1,233 @@
//! Independently specified version16 wire streams: one header, consecutive set bodies.
use craft_psd::{atn, descriptor::Value, error::PsdError};
use proptest::prelude::*;
fn u32be(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_be_bytes());
}
fn unicode(out: &mut Vec<u8>, units: &[u16]) {
u32be(out, units.len() as u32);
for unit in units {
out.extend_from_slice(&unit.to_be_bytes());
}
}
fn text(out: &mut Vec<u8>, value: &str) {
unicode(out, &value.encode_utf16().collect::<Vec<_>>());
}
fn id(out: &mut Vec<u8>, key: &str) {
if key.len() == 4 {
u32be(out, 0);
} else {
u32be(out, key.len() as u32);
}
out.extend_from_slice(key.as_bytes());
}
fn descriptor(items: &[(&str, &[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut out = Vec::new();
unicode(&mut out, &[]);
id(&mut out, "null");
u32be(&mut out, items.len() as u32);
for (key, kind, value) in items {
id(&mut out, key);
out.extend_from_slice(*kind);
out.extend(value);
}
out
}
fn list(descriptors: &[Vec<u8>]) -> Vec<u8> {
let mut out = Vec::new();
u32be(&mut out, descriptors.len() as u32);
for descriptor in descriptors {
out.extend_from_slice(b"Objc");
out.extend(descriptor);
}
out
}
fn string(value: &str) -> Vec<u8> {
let mut out = Vec::new();
text(&mut out, value);
out
}
fn set_header(out: &mut Vec<u8>, name: &[u16], actions: u32) {
unicode(out, name);
out.push(0);
u32be(out, actions);
}
fn action_header(out: &mut Vec<u8>, name: &str, items: u32) {
out.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // Fn, Shift, Control and colour.
text(out, name);
out.push(0);
u32be(out, items);
}
fn bare_item(out: &mut Vec<u8>) {
out.extend_from_slice(&[0, 1, 0, 0]);
out.extend_from_slice(b"longInvr");
u32be(out, 0);
u32be(out, 0);
}
fn fixture() -> (Vec<u8>, usize, Vec<u8>, Vec<u8>) {
let metadata = descriptor(&[
("actionDescriptionKey", b"TEXT", string("Description\0")),
("actionThumbnailTypeKey", b"long", 3i32.to_be_bytes().to_vec()),
("actionIconKey", b"TEXT", string("future-icon\0")),
("actionPreviewKey", b"bool", vec![0]),
("actionGenerativeKey", b"bool", vec![0]),
("actionPreconditionVal", b"long", (-1i32).to_be_bytes().to_vec()),
("futureDouble", b"doub", 0x7ff8_0000_0000_0042u64.to_be_bytes().to_vec()),
]);
let first = descriptor(&[("setColorIndex", b"long", 7i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[metadata]))]);
let empty = descriptor(&[("setColorIndex", b"long", 19i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[]))]);
let root = descriptor(&[("sets", b"VlLs", list(&[first, empty])), ("futureRaw", b"tdta", vec![0, 0, 0, 3, 0, 255, 7])]);
let mut payload = 16u32.to_be_bytes().to_vec();
payload.extend(root);
let mut raw = Vec::new();
u32be(&mut raw, payload.len() as u32);
raw.extend(&payload);
let outer = descriptor(&[("Msge", b"TEXT", string("Original note\0")), ("Cntn", b"bool", vec![1]), ("modernizedActionData", b"tdta", raw)]);
let mut bytes = 16u32.to_be_bytes().to_vec();
set_header(&mut bytes, &"Real\0".encode_utf16().collect::<Vec<_>>(), 1);
action_header(&mut bytes, "Invert\0", 1);
bare_item(&mut bytes);
let first_end = bytes.len();
set_header(&mut bytes, &[0xd800, 0], 0); // Empty real set and unpaired UTF16 remain exact.
set_header(&mut bytes, &"Action sidecar\0".encode_utf16().collect::<Vec<_>>(), 1);
action_header(&mut bytes, "Play for details\0", 1);
bytes.extend_from_slice(&[0, 1, 1, 2]);
bytes.extend_from_slice(b"TEXT");
u32be(&mut bytes, 4);
bytes.extend_from_slice(b"stop");
u32be(&mut bytes, 4);
bytes.extend_from_slice(b"Stop");
bytes.extend_from_slice(&(-1i32).to_be_bytes());
bytes.extend(&outer);
(bytes, first_end, outer, payload)
}
#[test]
fn one_version_and_three_set_bodies_preserve_raw_sidecar_descriptor_separately() {
let (bytes, first_end, outer, payload) = fixture();
assert_eq!(bytes.get(first_end..first_end + 4).unwrap(), &2u32.to_be_bytes());
let sets = atn::parse_sets(&bytes).unwrap();
assert_eq!(sets.len(), 3);
assert_eq!(sets[0].name.to_string_lossy(), "Real");
assert_eq!(sets[1].name.0, vec![0xd800, 0]);
assert!(sets[1].actions.is_empty());
let sidecar = sets[2].actions[0].items[0].descriptor.as_ref().unwrap();
assert_eq!(sidecar.to_bytes(), outer, "descriptor bytes have their own independent oracle");
assert_eq!(sidecar.get("modernizedActionData"), Some(&Value::RawData(payload)));
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
let single = atn::parse(&bytes).unwrap();
assert_eq!(single.name, sets[0].name);
assert_eq!(single.warnings, [format!("{} trailing bytes ignored", bytes.len() - first_end)]);
}
#[test]
fn every_truncated_later_set_fails_without_returning_partial_sets() {
let (bytes, first_end, _, _) = fixture();
// The first complete set alone is valid. The second complete empty set is another valid prefix.
let second_end = first_end + 13;
for cut in first_end + 1..bytes.len() {
if cut != second_end {
assert!(atn::parse_sets(&bytes[..cut]).is_err(), "cut {cut}");
}
}
assert_eq!(atn::parse_sets(&bytes[..first_end]).unwrap().len(), 1);
assert_eq!(atn::parse_sets(&bytes[..second_end]).unwrap().len(), 2);
let mut repeated = bytes[..first_end].to_vec();
repeated.extend_from_slice(&16u32.to_be_bytes());
set_header(&mut repeated, &[], 0);
assert!(atn::parse_sets(&repeated).is_err(), "a repeated version is not the measured set boundary");
assert!(atn::parse_sets(&16u32.to_be_bytes()).is_err());
assert!(atn::write_sets(&[]).is_err());
}
#[test]
fn forced_encoding_applies_to_all_sets_and_legacy_tails_remain_compatible() {
let (bytes, _, _, _) = fixture();
let sets = atn::parse_sets_with_encoding(&bytes, Some("shift_jis")).unwrap();
assert!(sets.iter().all(|set| set.encoding.as_deref() == Some("Shift_JIS")));
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
assert!(atn::parse_sets_with_encoding(&bytes, Some("utf-16")).is_err());
let mut legacy = 12u32.to_be_bytes().to_vec();
set_header(&mut legacy, &[], 0);
legacy.extend_from_slice(&[99, 1, 4]);
let old = atn::parse(&legacy).unwrap();
assert_eq!(atn::parse_sets(&legacy).unwrap(), vec![old.clone()]);
assert_eq!(old.warnings, ["3 trailing bytes ignored"]);
}
#[test]
fn whole_archive_set_and_action_budgets_are_checked_across_boundaries() {
let mut bytes = 16u32.to_be_bytes().to_vec();
for _ in 0..atn::MAX_SETS {
set_header(&mut bytes, &[], 0);
}
let sets = atn::parse_sets(&bytes).unwrap();
assert_eq!(sets.len(), atn::MAX_SETS);
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
set_header(&mut bytes, &[], 0);
assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_))));
let mut excess = sets;
excess.push(Default::default());
assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_))));
let mut bytes = 16u32.to_be_bytes().to_vec();
for _ in 0..2 {
set_header(&mut bytes, &[], (atn::MAX_TOTAL_ACTIONS / 2) as u32);
for _ in 0..atn::MAX_TOTAL_ACTIONS / 2 {
action_header(&mut bytes, "", 0);
}
}
let sets = atn::parse_sets(&bytes).unwrap();
assert_eq!(sets.iter().map(|set| set.actions.len()).sum::<usize>(), atn::MAX_TOTAL_ACTIONS);
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
set_header(&mut bytes, &[], 1);
action_header(&mut bytes, "", 0);
assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_))));
}
#[test]
fn aggregate_steps_are_bounded_before_a_later_action_allocates() {
let mut bytes = 16u32.to_be_bytes().to_vec();
set_header(&mut bytes, &[], 2);
for _ in 0..2 {
action_header(&mut bytes, "", (atn::MAX_TOTAL_ITEMS / 2) as u32);
for _ in 0..atn::MAX_TOTAL_ITEMS / 2 {
bare_item(&mut bytes);
}
}
let sets = atn::parse_sets(&bytes).unwrap();
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
set_header(&mut bytes, &[], 1);
action_header(&mut bytes, "", 1);
bare_item(&mut bytes);
assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_))));
let mut excess = sets;
excess[0].actions[0].items.push(Default::default());
assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_))));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn arbitrary_streams_reject_or_reach_a_stable_writer(data in prop::collection::vec(any::<u8>(), 0..2048)) {
if let Ok(files) = atn::parse_sets(&data) {
let encoded = atn::write_sets(&files).unwrap();
let again = atn::parse_sets(&encoded).unwrap();
prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded);
}
}
#[test]
fn mutated_independent_multiset_stream_rejects_or_reaches_a_stable_writer(index in any::<usize>(), value in any::<u8>()) {
let (mut bytes, _, _, _) = fixture();
let at = index % bytes.len(); bytes[at] = value;
if let Ok(files) = atn::parse_sets(&bytes) {
let encoded = atn::write_sets(&files).unwrap();
let again = atn::parse_sets(&encoded).unwrap();
prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded);
}
}
}
+10
View File
@@ -10,7 +10,17 @@ repository.workspace = true
[lints]
workspace = true
[features]
# `outlook`: compressed RTF ([MS-OXRTFCP]) and the text or encapsulated HTML ([MS-OXRTFEX]) of a
# mail item's body.
outlook = ["dep:craft-mime"]
[dependencies]
craft-codepage = { workspace = true }
# Character sets by Windows code page number, for the `outlook` module.
craft-mime = { workspace = true, optional = true }
# The East Asian multibyte code pages (932, 936, 949, 950).
encoding_rs = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+3 -2
View File
@@ -66,8 +66,9 @@ impl std::fmt::Display for CellRef {
}
}
/// A rectangle of cells, both corners included; `first` is the top left one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
/// A rectangle of cells, both corners included; `first` is the top left one. The default is
/// `A1`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Range {
pub first: CellRef,
pub last: CellRef,
+5 -1
View File
@@ -15,14 +15,18 @@ workspace = true
serde = ["dep:serde"]
# Final Cut Pro 7 XML (`fcp7`), read with craft-xml's bounded parser.
xml = ["dep:craft-xml"]
# OpenTimelineIO JSON (`otio`), with serde_json.
otio = ["dep:serde_json"]
[dependencies]
serde = { workspace = true, optional = true }
craft-xml = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
[dev-dependencies]
proptest = { workspace = true }
serde_json = { workspace = true }
[[test]]
name = "it"
required-features = ["xml"]
required-features = ["xml", "otio"]
+2 -1
View File
@@ -16,6 +16,7 @@ text formats that are documents of their own.
| `edl` | CMX 3600 edit decision lists: `parse` to the list as written (events, their lines and timecode fields, `M2`, `AUD`, clip-name, source-file, effect-name and locator comments), `write` for events the caller built, channel codes, reel names, the frame-rate guess. |
| `fcp7` (feature `xml`) | Final Cut Pro 7 XML (`xmeml` 4 and 5, the dialect Premiere Pro reads and writes) to and from `model`. Read with craft-xml, strictly and bounded. |
| `fcpxml` (feature `xml`) | FCPXML 1.9–1.11 to and from `model`: the primary storyline as V1, connected clips by lane, `ref-clip` compounds as nested sequences, rational times exact, transforms, opacity, volume and their keyframes, retiming through `timeMap`. |
| `otio` (feature `otio`) | OpenTimelineIO JSON to and from `model`: timelines, collections, stacks as nested sequences, gaps, transitions, markers, linear time warps and freeze frames. What OTIO has no schema for goes in `metadata.<namespace>`, and the model's `ext` is merged into it, so an application's own data round-trips. |
| crate root | `Format` (EDL, xmeml, FCPXML, OTIO, AAF, OMF), `detect_text`, `decode_text`. |
No file I/O. EDL and ALE give the document as the format writes it. The formats that describe a
@@ -23,7 +24,7 @@ whole edit read into and write from `model`; mapping `model` onto an application
stays in the application.
AAF and OMF are in `craft-aaf`.
Feature `xml`: the XML formats, on craft-xml. Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types.
Feature `xml`: the XML formats, on craft-xml. Feature `otio`: OpenTimelineIO, on serde_json. Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types.
## Specifications
+1 -1
View File
@@ -1325,7 +1325,7 @@ mod tests {
<asset-clip ref="a1" offset="3600s" duration="5s"><marker start="1s" duration="1/25s" value="m"/></asset-clip></spine></sequence></project></event></library></fcpxml>"#
);
let mut report = Report::default();
let iopts = ImportOptions { base_dir: None, catalog: &crate::fx::Standard, namespace: "test" };
let iopts = ImportOptions { base_dir: None, name: None, catalog: &crate::fx::Standard, namespace: "test" };
let imported = import(&xml, &iopts, &mut report).expect("import succeeds");
let xopts = ExportOptions {
relative_to: None,
+6 -1
View File
@@ -15,6 +15,7 @@
//! - [`edl`]: CMX 3600 edit decision lists.
//! - [`fcp7`] (feature `xml`): Final Cut Pro 7 XML (`xmeml`).
//! - [`fcpxml`] (feature `xml`): FCPXML 1.9–1.11.
//! - [`otio`] (feature `otio`): OpenTimelineIO JSON.
//! - [`Format`], [`detect_text`], [`decode_text`]: which interchange format a document is.
//!
//! No file I/O: documents are text or bytes, and media references are strings. Each format module
@@ -30,6 +31,8 @@ pub mod fcp7;
pub mod fcpxml;
pub mod fx;
pub mod model;
#[cfg(feature = "otio")]
pub mod otio;
pub mod path;
pub mod rate;
pub mod report;
@@ -116,7 +119,7 @@ pub enum Error {
}
impl Error {
#[cfg_attr(not(feature = "xml"), allow(dead_code))]
#[cfg_attr(not(any(feature = "xml", feature = "otio")), allow(dead_code))]
pub(crate) fn parse(format: Format, message: impl Into<String>) -> Error {
Error::Parse { format: format.name(), message: message.into() }
}
@@ -142,6 +145,8 @@ pub type Result<T> = std::result::Result<T, Error>;
pub struct ImportOptions<'a> {
/// Directory relative media paths are resolved against (normally the document's directory).
pub base_dir: Option<&'a str>,
/// Name for a timeline the document leaves unnamed.
pub name: Option<&'a str>,
/// The application's effects and generators (see [`model::Catalog`]; [`fx::Standard`] knows
/// only what the formats name themselves).
pub catalog: &'a dyn model::Catalog,
+2 -3
View File
@@ -14,7 +14,7 @@
//! supplies.
//! - **Generators** other than the ones every editor has: [`Generator::Other`] carries a name and
//! an opaque payload.
//! - **Extensions.** `ext` on items, sequences, tracks, clips, transitions and markers is JSON
//! - **Extensions.** `ext` on items (a sequence's is its item's), tracks, clips, transitions and markers is JSON
//! text the application owns. A format that can carry application metadata (OpenTimelineIO)
//! writes it under the application's namespace and gives it back on reading; the others ignore it.
@@ -540,12 +540,11 @@ pub struct Sequence {
pub markers: Vec<Marker>,
/// Timecode of the first frame, in frames.
pub start_timecode: i64,
pub ext: Ext,
}
impl Sequence {
pub fn new(settings: SequenceSettings) -> Self {
Self { settings, video_tracks: Vec::new(), audio_tracks: Vec::new(), markers: Vec::new(), start_timecode: 0, ext: None }
Self { settings, video_tracks: Vec::new(), audio_tracks: Vec::new(), markers: Vec::new(), start_timecode: 0 }
}
/// The end of the last clip.
pub fn duration(&self) -> Tick {
+2 -2
View File
@@ -723,7 +723,7 @@ fn absurd_timebase_is_bounded() {
);
let (imported, _) = fcp7_import(&doc, None);
for seq in &imported.sequences {
for format in [Format::Fcp7Xml, Format::Fcpxml] {
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] {
export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}"));
}
}
@@ -742,7 +742,7 @@ fn absurd_start_timecode_is_bounded() {
</clipitem></track></video></media></sequence></xmeml>"#;
let (imported, report) = fcp7_import(doc, None);
for seq in &imported.sequences {
for format in [Format::Fcp7Xml, Format::Fcpxml] {
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] {
export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}"));
}
assert_eq!(imported.project.sequence(*seq).map(|s| s.start_timecode), Some(0));
+1
View File
@@ -2,4 +2,5 @@
mod fcp7;
mod fcpxml;
mod otio;
mod support;
+5 -2
View File
@@ -11,7 +11,7 @@ use craft_timeline::model::{
TransitionId, VideoStreamInfo,
};
use craft_timeline::time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
use craft_timeline::{Error, ExportOptions, Format, ImportOptions, Imported, Report, fcp7, fcpxml};
use craft_timeline::{Error, ExportOptions, Format, ImportOptions, Imported, Report, fcp7, fcpxml, otio};
/// [`Standard`] and a few effects an application adds: a blur, a grade, two transitions.
pub struct TestCatalog;
@@ -31,6 +31,7 @@ fn extra() -> Vec<EffectDef> {
def("lumetri", "Lumetri Color", EffectKind::Video, "Color Correction", vec![float("exposure", "Exposure", 0.0)]),
def("dip_to_white", "Dip to White", EffectKind::VideoTransition, "Dissolve", vec![]),
def("push", "Push", EffectKind::VideoTransition, "Slide", vec![]),
def("iris_round", "Iris Round", EffectKind::VideoTransition, "Iris", vec![]),
]
}
@@ -49,7 +50,7 @@ impl Catalog for TestCatalog {
pub const NS: &str = "testapp";
pub fn iopts(base_dir: Option<&str>) -> ImportOptions<'_> {
ImportOptions { base_dir, catalog: &TestCatalog, namespace: NS }
ImportOptions { base_dir, name: None, catalog: &TestCatalog, namespace: NS }
}
pub fn xopts() -> ExportOptions<'static> {
@@ -307,6 +308,7 @@ pub fn import(bytes: &[u8], format: Format, base_dir: Option<&str>) -> Result<(I
let imported = match format {
Format::Fcp7Xml => fcp7::import(&text, &opts, &mut report),
Format::Fcpxml => fcpxml::import(&text, &opts, &mut report),
Format::Otio => otio::import(&text, &opts, &mut report),
_ => Err(Error::Other(format!("{} is not read here", format.name()))),
}?;
Ok((imported, report))
@@ -318,6 +320,7 @@ pub fn export(p: &Project, seq: ItemId, format: Format, opts: &ExportOptions) ->
let text = match format {
Format::Fcp7Xml => fcp7::export(p, seq, opts, &mut report),
Format::Fcpxml => fcpxml::export(p, seq, opts, &mut report),
Format::Otio => otio::export(p, seq, opts, &mut report),
_ => Err(Error::Other(format!("{} is not written here", format.name()))),
}?;
Ok((text.into_bytes(), report))
+3
View File
@@ -13,10 +13,13 @@ workspace = true
[features]
# Decoding every frame through the `image` crate's WebP decoder, with bounded memory.
frames = ["dep:image"]
# Lossy animations from RGBA frames through craft-video's VP8 key-frame encoder.
vp8 = ["dep:craft-video"]
[dependencies]
thiserror = { workspace = true }
image = { version = "0.25", default-features = false, features = ["webp"], optional = true }
craft-video = { workspace = true, optional = true, features = ["vp8-encode"] }
[dev-dependencies]
# Decodes the files the writer makes and writes still test files; not a product dependency.
+3
View File
@@ -15,6 +15,9 @@ workspace = true
test-support = []
[dependencies]
# The equation tree and its forms (math, math_latex, math_linear, math_symbols, math_tree), with
# symbol_fonts and Rgb, re-exported here under the paths they had.
craft-math = { workspace = true }
base64 = "0.23"
log = "0.4"
serde = { version = "1", features = ["derive", "rc"] }
+1 -6
View File
@@ -37,11 +37,7 @@ pub mod legacy_form;
pub mod line_dash;
pub mod link;
pub mod locale_number;
pub mod math;
pub mod math_latex;
pub mod math_linear;
pub mod math_symbols;
pub mod math_tree;
pub use craft_math::{math, math_latex, math_linear, math_symbols, math_tree, symbol_fonts};
pub mod numbering;
pub mod para;
pub mod picfx;
@@ -50,7 +46,6 @@ pub mod resolve;
pub mod section;
pub mod smart_art;
pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod walk;
pub mod wordart;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-762
View File
@@ -1,762 +0,0 @@
//! The linear format (UnicodeMath, the plain text Word builds equations from) → math tree.
//!
//! Grammar, informally: an expression is a sequence of atoms and operators. Atoms are characters,
//! bracket groups (`(…)`, `[…]`, `{…}`, `|…|`, `〖…〗` invisible, `├…┤` one-sided, `│`
//! separators), prefix structures (`√x`, `∑_a^b▒x`, `█(…)`, `■(…)`, `▭x`, `□x`, `¯x`, `▁x`,
//! `⏞x`, `⏟x`, `⟡x`), function names (`sin x`, `log_2 x`, `lim┬(n→∞) x`) and placeholders (`⬚`).
//! Postfix operators apply to the atom before them: `^` `_` scripts, combining accents, `┬` `┴`
//! limits. `/` `⁄` `∕` `¦` take the operands on either side (an operand is a run of atoms up to
//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
/// Function names set upright and applied to the following operand.
pub const FUNCTION_NAMES: &[&str] = &[
"arccos", "arccot", "arccsc", "arcsec", "arcsin", "arctan", "arg", "cos", "cosh", "cot", "coth", "csc", "csch", "deg", "det", "dim", "exp",
"gcd", "hom", "inf", "ker", "lg", "lim", "liminf", "limsup", "ln", "log", "max", "min", "mod", "Pr", "sec", "sech", "sin", "sinh", "sup", "tan",
"tanh",
];
/// Length of the longest of [`FUNCTION_NAMES`].
const MAX_NAME: usize = {
let (mut max, mut i) = (0, 0);
while i < FUNCTION_NAMES.len() {
if FUNCTION_NAMES[i].len() > max {
max = FUNCTION_NAMES[i].len();
}
i += 1;
}
max
};
/// Function names whose subscripts go underneath (`lim_(n→∞)`).
const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"];
/// The empty-argument placeholder.
pub const PLACEHOLDER: char = '⬚';
/// Parse linear-format text. Never fails: what isn't understood stays as text.
pub fn parse(s: &str) -> Arg {
let mut p = P { c: s.chars().take(20_000).collect(), i: 0, depth: 0, no_limits: 0, steps: 0 };
let mut out = Vec::new();
// Stray closers at the top level are text.
while p.i < p.c.len() {
out.extend(p.expr(&[]));
if let Some(c) = p.peek() {
p.i += 1;
out.push(text(c));
}
}
let mut out = clean(out);
// `expr#(n)`: a numbered equation.
if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
out = vec![MNode::EqArr { rows: vec![out] }];
}
out
}
fn text(c: char) -> MNode {
MNode::Run(MRun::new(c.to_string()))
}
/// Closing bracket for an opening one.
pub fn closer(c: char) -> Option<char> {
Some(match c {
'(' => ')',
'[' => ']',
'{' => '}',
'⟨' => '⟩',
'〈' => '〉',
'⌈' => '⌉',
'⌊' => '⌋',
'⟦' => '⟧',
'〖' => '〗',
_ => return None,
})
}
fn is_closer(c: char) -> bool {
matches!(c, ')' | ']' | '}' | '⟩' | '〉' | '⌉' | '⌋' | '⟧' | '〗' | '┤')
}
/// Prefix characters that make a structure from the operand after them.
fn is_prefix(c: char) -> bool {
matches!(c, '√' | '∛' | '∜' | '█' | '■' | '▭' | '□' | '¯' | '▁' | '⟡' | '⏞' | '⏟' | '⏜' | '⏝' | '⎴' | '⎵') || is_nary_char(c)
}
/// Combining marks used as accents.
pub fn is_combining(c: char) -> bool {
matches!(c as u32, 0x300..=0x36F | 0x20D0..=0x20FF)
}
/// Characters that end an operand.
fn is_boundary(c: char) -> bool {
matches!(c, ' ' | '&' | '@' | '#' | '▒' | '│' | '/' | '⁄' | '∕' | '¦' | '\u{2061}')
|| is_closer(c)
|| matches!(math_class(c), MClass::Bin | MClass::Rel | MClass::Punct)
}
struct P {
c: Vec<char>,
i: usize,
depth: usize,
/// Inside a brace's operand: limits are left for the brace.
no_limits: usize,
/// Work done (bounds hostile input).
steps: usize,
}
const MAX_STEPS: usize = 400_000;
impl P {
fn peek(&self) -> Option<char> {
self.c.get(self.i).copied()
}
fn peek_at(&self, k: usize) -> Option<char> {
self.c.get(self.i + k).copied()
}
fn tired(&mut self) -> bool {
self.steps += 1;
self.steps > MAX_STEPS || self.depth > MAX_DEPTH
}
/// The rest of the input as text (when too deep or too long).
fn rest(&mut self) -> Arg {
let t: String = self.c.get(self.i..).unwrap_or(&[]).iter().collect();
self.i = self.c.len();
if t.is_empty() { Vec::new() } else { vec![MNode::Run(MRun::new(t))] }
}
/// A sequence up to one of `stops` (not consumed) or the end.
fn expr(&mut self, stops: &[char]) -> Arg {
self.depth += 1;
let mut out: Arg = Vec::new();
if self.tired() {
self.depth -= 1;
return self.rest();
}
while let Some(c) = self.peek() {
if stops.contains(&c) || (is_closer(c) && !stops.is_empty()) {
break;
}
if self.tired() {
out.extend(self.rest());
break;
}
match c {
'/' | '⁄' | '∕' | '¦' => {
self.i += 1;
let start = operand_start(&out);
// A numerator that is already deep stays as it is: `/` is text then.
if out.get(start..).is_some_and(any_too_deep) {
out.push(text(c));
continue;
}
let num = strip_parens(out.drain(start..).collect());
let den = self.operand(stops);
let kind = match c {
'⁄' => FracKind::Skewed,
'∕' => FracKind::Linear,
'¦' => FracKind::NoBar,
_ => FracKind::Bar,
};
out.push(MNode::Frac { kind, num, den });
}
'^' | '_' => {
let pre = match out.last() {
None => true,
Some(MNode::Run(r)) => r.text == " " || r.text.chars().all(|c| is_boundary(c) && c != '#'),
_ => false,
};
if pre {
out.push(self.prescript(stops));
} else {
self.i += 1;
let s = self.script_operand(stops);
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(s);
} else {
let base = out.pop();
out.push(attach_script(base, c == '^', s));
}
}
}
'┬' | '┴' => {
self.i += 1;
let lim = self.script_operand(stops);
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(lim);
} else {
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
}
}
c if is_combining(c) => {
self.i += 1;
if out.last().is_some_and(too_deep) {
out.push(text(c));
} else {
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
}
}
'▒' | '\u{2061}' => self.i += 1,
' ' => {
self.i += 1;
out.push(text(' '));
}
_ => {
let atom = self.atom(stops);
out.extend(atom);
}
}
}
self.depth -= 1;
out
}
/// One atom (a character, a bracket group, a structure or a function application).
fn atom(&mut self, stops: &[char]) -> Arg {
let Some(c) = self.peek() else { return Vec::new() };
if let Some(close) = closer(c) {
self.i += 1;
return vec![self.group(Some(c), &[close])];
}
if c == '├' {
self.i += 1;
if let Some(d) = self.explicit_group() {
return vec![d];
}
return vec![self.group(None, &[')', ']', '}', '⟩', '⌉', '⌋', '|', '‖'])];
}
if (c == '|' || c == '‖') && self.c.get(self.i + 1..).is_some_and(|r| r.contains(&c)) {
self.i += 1;
return vec![self.group(Some(c), &[c])];
}
if is_prefix(c) {
self.i += 1;
return vec![self.prefix(c, stops)];
}
if c.is_ascii_alphabetic() {
return self.word(stops);
}
self.i += 1;
vec![text(c)]
}
/// A bracket group after its opening character; `closers` end it.
fn group(&mut self, open: Option<char>, closers: &[char]) -> MNode {
let mut stops: Vec<char> = closers.to_vec();
stops.extend(['┤', '│']);
let mut elems = Vec::new();
let mut end = None;
loop {
let e = self.expr(&stops);
elems.push(e);
match self.peek() {
Some('│') => self.i += 1,
Some('┤') => {
self.i += 1;
break;
}
Some(c) if closers.contains(&c) => {
self.i += 1;
end = Some(c);
break;
}
// Another closer: the group ends unclosed there.
_ => break,
}
if self.tired() {
break;
}
}
let beg = open.filter(|c| *c != '〖');
let end = end.filter(|c| *c != '〗');
let sep = if elems.len() > 1 { Some('|') } else { None };
MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }
}
/// `├X … Y┤`: brackets given explicitly (any bracket characters, either side optional), the
/// cursor just after `├`. `None` (cursor unchanged) when there's no matching `┤`.
fn explicit_group(&mut self) -> Option<MNode> {
let mut level = 1usize;
let mut k = self.i;
while let Some(c) = self.c.get(k) {
match c {
'├' => level += 1,
'┤' => {
level -= 1;
if level == 0 {
break;
}
}
_ => {}
}
k += 1;
}
if level != 0 {
return None;
}
let bracket = |c: &char| matches!(c, '(' | ')' | '[' | ']' | '{' | '}' | '⟨' | '⟩' | '⌈' | '⌉' | '⌊' | '⌋' | '|' | '‖' | '⟦' | '⟧');
let mut inner: Vec<char> = self.c.get(self.i..k).unwrap_or(&[]).to_vec();
self.i = k + 1;
let beg = if inner.first().is_some_and(bracket) { Some(inner.remove(0)) } else { None };
// A closing bracket at the end that pairs with an opening one inside is content.
let paired = |inner: &[char]| -> bool {
let Some((last, body)) = inner.split_last() else { return false };
let opener = match last {
')' => '(',
']' => '[',
'}' => '{',
'⟩' => '⟨',
'⌉' => '⌈',
'⌋' => '⌊',
'⟧' => '⟦',
_ => return false,
};
body.iter().filter(|c| **c == opener).count() > body.iter().filter(|c| *c == last).count()
};
let end = if !inner.is_empty() && inner.last().is_some_and(bracket) && !paired(&inner) { inner.pop() } else { None };
let mut sub = P { c: inner, i: 0, depth: self.depth + 1, no_limits: 0, steps: self.steps };
let mut elems = vec![Vec::new()];
while sub.i < sub.c.len() {
if let Some(e) = elems.last_mut() {
e.extend(sub.expr(&['│']));
}
match sub.peek() {
Some('│') => {
sub.i += 1;
elems.push(Vec::new());
}
Some(c) => {
sub.i += 1;
if let Some(e) = elems.last_mut() {
e.push(text(c));
}
}
None => {}
}
if sub.tired() {
break;
}
}
self.steps = sub.steps;
let sep = if elems.len() > 1 { Some('|') } else { None };
Some(MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems })
}
/// A prefix structure after its character.
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
match c {
'√'..='∜' => {
let mut deg = match c {
'∛' => vec![text('3')],
'∜' => vec![text('4')],
_ => Vec::new(),
};
let mut e = self.operand(stops);
if let Some(k) = e.iter().position(|n| matches!(n, MNode::Run(r) if r.text == "&")) {
let rest = e.split_off(k + 1);
e.pop();
deg = e;
e = rest;
}
MNode::Rad { deg_hide: deg.is_empty(), deg, e }
}
'█' | '■' => {
if self.peek() == Some('(') {
self.i += 1;
let inner = self.expr(&[')']);
if self.peek() == Some(')') {
self.i += 1;
}
grid(c == '■', inner)
} else {
text(c)
}
}
'▭' => MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.operand(stops) },
'□' => MNode::Boxed { e: self.operand(stops) },
'¯' => MNode::Bar { top: true, e: self.operand(stops) },
'▁' => MNode::Bar { top: false, e: self.operand(stops) },
'⟡' => MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.operand(stops) },
// A label (`┴`/`┬`) after the operand belongs to the whole brace.
'⏞' | '⏜' | '⎴' | '⏟' | '⏝' | '⎵' => {
self.no_limits += 1;
let e = self.operand(stops);
self.no_limits -= 1;
MNode::GroupChr { chr: c, top: matches!(c, '⏞' | '⏜' | '⎴'), e }
}
_ => {
// N-ary operator: limits, then the operand.
let (mut sub, mut sup) = (Vec::new(), Vec::new());
let (mut has_sub, mut has_sup) = (false, false);
loop {
match self.peek() {
Some('_') => {
self.i += 1;
sub = self.script_operand(stops);
has_sub = true;
}
Some('^') => {
self.i += 1;
sup = self.script_operand(stops);
has_sup = true;
}
_ => break,
}
}
if self.peek() == Some('▒') {
self.i += 1;
}
let e = if self.peek().is_some_and(|c| c == ' ') { Vec::new() } else { self.operand(stops) };
MNode::Nary { chr: c, lim_loc: None, grow: false, sub_hide: !has_sub, sup_hide: !has_sup, sub, sup, e }
}
}
}
/// Letters: a function application when they spell a function name, else one letter.
fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i;
let mut end = start;
// Look no further than the longest name (and one past it, to see the run is longer): a
// scan to the end of every run made a long run of letters quadratic (CWE-407).
while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
end += 1;
}
let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
if !FUNCTION_NAMES.contains(&word.as_str()) {
self.i = start + 1;
return vec![text(self.c.get(start).copied().unwrap_or(' '))];
}
self.i = end;
let plain = vec![MNode::Run(MRun::plain(word.clone()))];
// Limits and scripts on the name.
let name = match self.peek() {
Some('┬') | Some('┴') => {
let upper = self.peek() == Some('┴');
self.i += 1;
let lim = self.script_operand(stops);
vec![MNode::Lim { upper, e: plain, lim }]
}
Some('_') if LIMIT_NAMES.contains(&word.as_str()) => {
self.i += 1;
let lim = self.script_operand(stops);
vec![MNode::Lim { upper: false, e: plain, lim }]
}
Some(c @ ('^' | '_')) => {
self.i += 1;
let s = self.script_operand(stops);
let mut n = attach_script(Some(MNode::Run(MRun::plain(word.clone()))), c == '^', s);
if let Some(c2 @ ('^' | '_')) = self.peek() {
self.i += 1;
let s2 = self.script_operand(stops);
n = attach_script(Some(n), c2 == '^', s2);
}
vec![n]
}
_ => plain,
};
// The operand, after an optional space or function-application mark.
if matches!(self.peek(), Some(' ') | Some('\u{2061}') | Some('▒')) {
self.i += 1;
}
let next = self.peek();
let applies = next.is_some_and(|c| !stops.contains(&c) && !is_closer(c) && (!is_boundary(c) || c == '(' || matches!(c, '−' | '-' | '+')));
if !applies {
return name;
}
let e = self.operand(stops);
if e.is_empty() {
return name;
}
vec![MNode::Func { name, e }]
}
/// Pre-scripts (`_a^b x`): the scripts, then the base.
fn prescript(&mut self, stops: &[char]) -> MNode {
let (mut sub, mut sup) = (Vec::new(), Vec::new());
while let Some(c @ ('^' | '_')) = self.peek() {
self.i += 1;
let s = self.script_operand(stops);
if c == '^' {
sup = s;
} else {
sub = s;
}
}
if self.peek() == Some('▒') {
self.i += 1;
}
let base = self.operand(stops);
MNode::Script { kind: ScriptKind::Pre, base, sub, sup }
}
/// An operand: a parenthesised group (parentheses dropped) or a run of atoms with their
/// postfix scripts, accents and limits, up to an operator or space.
fn operand(&mut self, stops: &[char]) -> Arg {
if self.tired() {
return self.rest();
}
self.depth += 1;
let mut out: Arg = Vec::new();
if self.peek() == Some('(') {
self.i += 1;
let g = self.group(Some('('), &[')']);
let inner = match g {
MNode::Delim { elems, end: Some(')'), .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
other => vec![other],
};
out = inner;
self.postfix(&mut out, stops, true);
} else if self.peek() == Some('〖') {
self.i += 1;
let g = self.group(Some('〖'), &['〗']);
out = match g {
MNode::Delim { elems, .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
other => vec![other],
};
self.postfix(&mut out, stops, true);
} else {
// A signed operand (`e^-x`, `−b`).
if let Some(c @ ('−' | '-' | '+' | '±' | '∓')) = self.peek()
&& self.peek_at(1).is_some_and(|n| !is_boundary(n))
{
self.i += 1;
out.push(text(c));
}
while let Some(c) = self.peek() {
if stops.contains(&c) || is_boundary(c) || self.tired() {
break;
}
let a = self.atom(stops);
if a.is_empty() {
break;
}
out.extend(a);
self.postfix(&mut out, stops, false);
}
if out.is_empty()
&& let Some(c) = self.peek()
&& c != ' '
&& !stops.contains(&c)
&& !is_closer(c)
&& !matches!(c, '/' | '⁄' | '∕' | '¦' | '&' | '@' | '#' | '│' | '▒')
{
// A lone operator as operand.
self.i += 1;
out.push(text(c));
}
}
self.depth -= 1;
out
}
/// Postfix operators on the last atom of `out` (`whole`: on all of `out` as one group).
fn postfix(&mut self, out: &mut Arg, stops: &[char], whole: bool) {
while let Some(c) = self.peek() {
if self.tired() {
break;
}
let base = |out: &mut Arg| -> Option<MNode> {
if whole && out.len() != 1 {
if out.is_empty() {
return None;
}
return Some(MNode::Delim { beg: None, end: None, sep: None, grow: true, shp_match: false, elems: vec![std::mem::take(out)] });
}
out.pop()
};
if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
break;
}
// The base is already deep: the operator is text (see `too_deep`).
let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
self.i += 1;
if deep {
let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
out.push(text(c));
out.extend(s);
continue;
}
match c {
'^' | '_' => {
let s = self.script_operand(stops);
let b = base(out);
out.push(attach_script(b, c == '^', s));
}
'┬' | '┴' => {
let lim = self.script_operand(stops);
let b = base(out);
out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
}
_ => {
let b = base(out);
out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
}
}
}
}
/// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of
/// letters (and digits) or one other character.
fn script_operand(&mut self, stops: &[char]) -> Arg {
if self.tired() {
return self.rest();
}
self.depth += 1;
let out = match self.peek() {
Some('(') => {
self.i += 1;
match self.group(Some('('), &[')']) {
MNode::Delim { elems, end: Some(')'), .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
other => vec![other],
}
}
Some('〖') => {
self.i += 1;
match self.group(Some('〖'), &['〗']) {
MNode::Delim { elems, .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(),
other => vec![other],
}
}
Some(c) if is_prefix(c) || closer(c).is_some() => self.atom(stops),
Some(c) if c.is_ascii_digit() => {
let start = self.i;
while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
vec![MNode::Run(MRun::new(t))]
}
Some(c @ ('−' | '-' | '+' | '±' | '∓')) if self.peek_at(1).is_some_and(|n| !is_boundary(n)) => {
self.i += 1;
let mut v = vec![text(c)];
v.extend(self.script_operand(stops));
v
}
Some(c) if c.is_alphabetic() => {
// Word (#1609): the whole run of letters and digits up to a space or operator, so
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i;
while self.peek().is_some_and(char::is_alphanumeric) {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
// A known function name stays upright, as Word builds it.
vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })]
}
Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => {
self.i += 1;
vec![text(c)]
}
_ => Vec::new(),
};
self.depth -= 1;
out
}
}
/// Where the trailing operand of `out` starts (after the last operator or space).
fn operand_start(out: &[MNode]) -> usize {
let mut k = out.len();
while k > 0 {
match out.get(k - 1) {
Some(MNode::Run(r)) if r.text.chars().all(is_boundary) => break,
_ => k -= 1,
}
}
k
}
/// A single parenthesised group used as an operand loses its parentheses.
pub fn strip_parens(mut a: Arg) -> Arg {
if a.len() == 1
&& let Some(MNode::Delim { beg: Some('('), end: Some(')'), elems, .. } | MNode::Delim { beg: None, end: None, elems, .. }) = a.first_mut()
&& elems.len() == 1
{
return elems.pop().unwrap_or_default();
}
a
}
fn attach_script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
match base {
Some(MNode::Script { kind: ScriptKind::Sub, base, sub, .. }) if sup => MNode::Script { kind: ScriptKind::SubSup, base, sub, sup: s },
Some(MNode::Script { kind: ScriptKind::Sup, base, sup: sp, .. }) if !sup => MNode::Script { kind: ScriptKind::SubSup, base, sub: s, sup: sp },
b => {
let base = b.map(|b| vec![b]).unwrap_or_default();
if sup {
MNode::Script { kind: ScriptKind::Sup, base, sub: Vec::new(), sup: s }
} else {
MNode::Script { kind: ScriptKind::Sub, base, sub: s, sup: Vec::new() }
}
}
}
}
/// `█(a@b)` equation array / `■(a&b@c&d)` matrix from the parsed inside.
fn grid(matrix: bool, inner: Arg) -> MNode {
let mut rows: Vec<Vec<Arg>> = vec![vec![Vec::new()]];
for n in inner {
match &n {
MNode::Run(r) if r.text == "@" => rows.push(vec![Vec::new()]),
MNode::Run(r) if r.text == "&" && matrix => {
if let Some(row) = rows.last_mut() {
row.push(Vec::new());
}
}
_ => {
if let Some(cell) = rows.last_mut().and_then(|r| r.last_mut()) {
cell.push(n);
}
}
}
}
if matrix {
MNode::Matrix { rows, col_jc: Vec::new() }
} else {
MNode::EqArr { rows: rows.into_iter().map(|r| r.into_iter().flatten().collect()).collect() }
}
}
/// Drop placeholders and build-up spaces, merge runs; recursively.
fn clean(a: Arg) -> Arg {
let mut out = Vec::with_capacity(a.len());
for n in a {
match n {
MNode::Run(r) if r.text == " " || r.text.chars().all(|c| c == PLACEHOLDER) => {}
other => out.push(map_args(other, &clean)),
}
}
merge_runs(out)
}
/// Apply `f` to every argument of a node.
pub fn map_args(n: MNode, f: &dyn Fn(Arg) -> Arg) -> MNode {
match n {
MNode::Run(r) => MNode::Run(r),
MNode::Frac { kind, num, den } => MNode::Frac { kind, num: f(num), den: f(den) },
MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: f(base), sub: f(sub), sup: f(sup) },
MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: f(deg), deg_hide, e: f(e) },
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: f(sub), sup: f(sup), e: f(e) }
}
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(f).collect() }
}
MNode::Func { name, e } => MNode::Func { name: f(name), e: f(e) },
MNode::Lim { upper, e, lim } => MNode::Lim { upper, e: f(e), lim: f(lim) },
MNode::Acc { chr, e } => MNode::Acc { chr, e: f(e) },
MNode::Bar { top, e } => MNode::Bar { top, e: f(e) },
MNode::BorderBox { hide, strike, e } => MNode::BorderBox { hide, strike, e: f(e) },
MNode::Boxed { e } => MNode::Boxed { e: f(e) },
MNode::GroupChr { chr, top, e } => MNode::GroupChr { chr, top, e: f(e) },
MNode::EqArr { rows } => MNode::EqArr { rows: rows.into_iter().map(f).collect() },
MNode::Matrix { rows, col_jc } => MNode::Matrix { rows: rows.into_iter().map(|r| r.into_iter().map(f).collect()).collect(), col_jc },
MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => MNode::Phant { show, zero_wid, zero_asc, zero_desc, e: f(e) },
}
}
-390
View File
@@ -1,390 +0,0 @@
//! Named math characters: Math AutoCorrect while typing in an equation (`\alpha` + space → α)
//! and LaTeX conversion. Names are the usual TeX names plus the linear-format operators
//! (`\sqrt` → √, `\above` → ┴, `\matrix` → ■…).
/// (name without the backslash, character). When a character has several names the first one
/// listed is the one LaTeX output uses.
pub const SYMBOLS: &[(&str, char)] = &[
// Greek.
("alpha", 'α'),
("beta", 'β'),
("gamma", 'γ'),
("delta", 'δ'),
("epsilon", 'ϵ'),
("varepsilon", 'ε'),
("zeta", 'ζ'),
("eta", 'η'),
("theta", 'θ'),
("vartheta", 'ϑ'),
("iota", 'ι'),
("kappa", 'κ'),
("varkappa", 'ϰ'),
("lambda", 'λ'),
("mu", 'μ'),
("nu", 'ν'),
("xi", 'ξ'),
("omicron", 'ο'),
("pi", 'π'),
("varpi", 'ϖ'),
("rho", 'ρ'),
("varrho", 'ϱ'),
("sigma", 'σ'),
("varsigma", 'ς'),
("tau", 'τ'),
("upsilon", 'υ'),
("phi", 'ϕ'),
("varphi", 'φ'),
("chi", 'χ'),
("psi", 'ψ'),
("omega", 'ω'),
("Alpha", 'Α'),
("Beta", 'Β'),
("Gamma", 'Γ'),
("Delta", 'Δ'),
("Epsilon", 'Ε'),
("Zeta", 'Ζ'),
("Eta", 'Η'),
("Theta", 'Θ'),
("Iota", 'Ι'),
("Kappa", 'Κ'),
("Lambda", 'Λ'),
("Mu", 'Μ'),
("Nu", 'Ν'),
("Xi", 'Ξ'),
("Omicron", 'Ο'),
("Pi", 'Π'),
("Rho", 'Ρ'),
("Sigma", 'Σ'),
("Tau", 'Τ'),
("Upsilon", 'Υ'),
("Phi", 'Φ'),
("Chi", 'Χ'),
("Psi", 'Ψ'),
("Omega", 'Ω'),
("digamma", 'ϝ'),
// Binary operators.
("pm", '±'),
("mp", '∓'),
("times", '×'),
("div", '÷'),
("cdot", '⋅'),
("bullet", '∙'),
("ast", '∗'),
("star", '⋆'),
("circ", '∘'),
("oplus", '⊕'),
("ominus", '⊖'),
("otimes", '⊗'),
("oslash", '⊘'),
("odot", '⊙'),
("cup", '∪'),
("cap", '∩'),
("uplus", '⊎'),
("sqcup", '⊔'),
("sqcap", '⊓'),
("vee", '∨'),
("wedge", '∧'),
("setminus", '∖'),
("backslash", '\\'),
("wr", '≀'),
("diamond", '⋄'),
("dagger", '†'),
("ddagger", '‡'),
("amalg", '⨿'),
("dotplus", '∔'),
("ltimes", '⋉'),
("rtimes", '⋊'),
// Relations.
("le", '≤'),
("leq", '≤'),
("ge", '≥'),
("geq", '≥'),
("ne", '≠'),
("neq", '≠'),
("approx", '≈'),
("equiv", '≡'),
("sim", '∼'),
("simeq", '≃'),
("cong", '≅'),
("propto", '∝'),
("ll", '≪'),
("gg", '≫'),
("prec", '≺'),
("succ", '≻'),
("preceq", '≼'),
("succeq", '≽'),
("subset", '⊂'),
("supset", '⊃'),
("subseteq", '⊆'),
("supseteq", '⊇'),
("sqsubseteq", '⊑'),
("sqsupseteq", '⊒'),
("in", '∈'),
("ni", '∋'),
("notin", '∉'),
("perp", '⊥'),
("parallel", '∥'),
("mid", '∣'),
("nmid", '∤'),
("asymp", '≍'),
("doteq", '≐'),
("models", '⊨'),
("vdash", '⊢'),
("dashv", '⊣'),
("bowtie", '⋈'),
("coloneq", '≔'),
("defeq", '≝'),
("triangleq", '≜'),
("nless", '≮'),
("ngtr", '≯'),
("nleq", '≰'),
("ngeq", '≱'),
("nsim", '≁'),
("ncong", '≇'),
("nequiv", '≢'),
("nsubset", '⊄'),
("nsupset", '⊅'),
("nsubseteq", '⊈'),
("nsupseteq", '⊉'),
("subsetneq", '⊊'),
("supsetneq", '⊋'),
("lesssim", '≲'),
("gtrsim", '≳'),
("therefore", '∴'),
("because", '∵'),
// Arrows.
("rightarrow", '→'),
("to", '→'),
("leftarrow", '←'),
("gets", '←'),
("leftrightarrow", '↔'),
("uparrow", '↑'),
("downarrow", '↓'),
("updownarrow", '↕'),
("Rightarrow", '⇒'),
("Leftarrow", '⇐'),
("Leftrightarrow", '⇔'),
("Uparrow", '⇑'),
("Downarrow", '⇓'),
("Updownarrow", '⇕'),
("longrightarrow", '⟶'),
("longleftarrow", '⟵'),
("longleftrightarrow", '⟷'),
("Longrightarrow", '⟹'),
("Longleftarrow", '⟸'),
("Longleftrightarrow", '⟺'),
("implies", '⟹'),
("iff", '⟺'),
("mapsto", '↦'),
("longmapsto", '⟼'),
("nearrow", '↗'),
("searrow", '↘'),
("swarrow", '↙'),
("nwarrow", '↖'),
("hookleftarrow", '↩'),
("hookrightarrow", '↪'),
("leftharpoonup", '↼'),
("leftharpoondown", '↽'),
("rightharpoonup", '⇀'),
("rightharpoondown", '⇁'),
("rightleftharpoons", '⇌'),
("leftrightharpoons", '⇋'),
("circlearrowleft", '↺'),
("circlearrowright", '↻'),
// Big operators.
("sum", '∑'),
("prod", '∏'),
("coprod", '∐'),
("int", '∫'),
("iint", '∬'),
("iiint", '∭'),
("oint", '∮'),
("oiint", '∯'),
("oiiint", '∰'),
("bigcup", '⋃'),
("bigcap", '⋂'),
("bigvee", '⋁'),
("bigwedge", '⋀'),
("bigoplus", '⨁'),
("bigotimes", '⨂'),
("bigodot", '⨀'),
("biguplus", '⨄'),
("bigsqcup", '⨆'),
// Brackets.
("langle", '⟨'),
("rangle", '⟩'),
("lceil", '⌈'),
("rceil", '⌉'),
("lfloor", '⌊'),
("rfloor", '⌋'),
("lbrack", '['),
("rbrack", ']'),
("lbrace", '{'),
("rbrace", '}'),
("Vert", '‖'),
("norm", '‖'),
("vert", '|'),
("lbbrack", '⟦'),
("rbbrack", '⟧'),
// Miscellaneous.
("infty", '∞'),
("partial", '∂'),
("nabla", '∇'),
("forall", '∀'),
("exists", '∃'),
("nexists", '∄'),
("neg", '¬'),
("lnot", '¬'),
("emptyset", '∅'),
("varnothing", '∅'),
("hbar", 'ℏ'),
("ell", 'ℓ'),
("Re", 'ℜ'),
("Im", 'ℑ'),
("wp", '℘'),
("aleph", 'ℵ'),
("beth", 'ℶ'),
("gimel", 'ℷ'),
("daleth", 'ℸ'),
("degree", '°'),
("angle", '∠'),
("measuredangle", '∡'),
("sphericalangle", '∢'),
("rightangle", '∟'),
("triangle", '△'),
("square", '□'),
("blacksquare", '■'),
("qed", '∎'),
("top", '⊤'),
("bot", '⊥'),
("cdots", '⋯'),
("ldots", '…'),
("dots", '…'),
("vdots", '⋮'),
("ddots", '⋱'),
("adots", '⋰'),
("prime", '′'),
("dprime", '″'),
("trprime", '‴'),
("dd", 'ⅆ'),
("Dd", 'ⅅ'),
("ee", 'ⅇ'),
("ii", 'ⅈ'),
("jj", 'ⅉ'),
("imath", 'ı'),
("jmath", 'ȷ'),
("complement", '∁'),
("Angstrom", 'Å'),
("mho", '℧'),
("eth", 'ð'),
("flat", '♭'),
("natural", '♮'),
("sharp", '♯'),
("clubsuit", '♣'),
("diamondsuit", '♢'),
("heartsuit", '♡'),
("spadesuit", '♠'),
// Spaces.
("quad", '\u{2003}'),
("emsp", '\u{2003}'),
("ensp", '\u{2002}'),
("thicksp", '\u{2005}'),
("medsp", '\u{205F}'),
("thinsp", '\u{2009}'),
("hairsp", '\u{200A}'),
("nbsp", '\u{A0}'),
("zwsp", '\u{200B}'),
// Linear-format operators (build-up).
("sqrt", '√'),
("cbrt", '∛'),
("qdrt", '∜'),
("matrix", '■'),
("eqarray", '█'),
("above", '┴'),
("below", '┬'),
("of", '▒'),
("naryand", '▒'),
("funcapply", '\u{2061}'),
("box", '□'),
("rect", '▭'),
("overbar", '¯'),
("underbar", '▁'),
("phantom", '⟡'),
("overbrace", '⏞'),
("underbrace", '⏟'),
("overparen", '⏜'),
("underparen", '⏝'),
("atop", '¦'),
("over", '/'),
("ldiv", '∕'),
("sdiv", '⁄'),
("begin", '〖'),
("end", '〗'),
("left", '├'),
("right", '┤'),
("eqno", '#'),
("placeholder", '⬚'),
// Accents (combining, typed after the base).
("hat", '\u{302}'),
("check", '\u{30C}'),
("tilde", '\u{303}'),
("acute", '\u{301}'),
("grave", '\u{300}'),
("dot", '\u{307}'),
("ddot", '\u{308}'),
("dddot", '\u{20DB}'),
("breve", '\u{306}'),
("bar", '\u{305}'),
("Bar", '\u{33F}'),
("vec", '\u{20D7}'),
("tvec", '\u{20E1}'),
("lhvec", '\u{20D0}'),
("hvec", '\u{20D1}'),
];
/// The character for a name (`alpha` → α), including `doubleR`, `scriptL`, `frakturg`.
pub fn symbol(name: &str) -> Option<char> {
if let Some((_, c)) = SYMBOLS.iter().find(|(n, _)| *n == name) {
return Some(*c);
}
for (prefix, scr) in [("double", crate::math::MScr::DoubleStruck), ("script", crate::math::MScr::Script), ("fraktur", crate::math::MScr::Fraktur)]
{
if let Some(rest) = name.strip_prefix(prefix) {
let mut cs = rest.chars();
if let (Some(c), None) = (cs.next(), cs.next())
&& c.is_ascii_alphanumeric()
{
let m = crate::math::math_alnum(c, false, false, scr);
if m != c {
return Some(m);
}
}
}
}
None
}
/// The (first) name of a character.
pub fn name_of(c: char) -> Option<&'static str> {
SYMBOLS.iter().find(|(_, s)| *s == c).map(|(n, _)| *n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_resolve() {
assert_eq!(symbol("alpha"), Some('α'));
assert_eq!(symbol("doubleR"), Some('ℝ'));
assert_eq!(symbol("scriptL"), Some('ℒ'));
assert_eq!(symbol("nonesuch"), None);
assert_eq!(name_of('≤'), Some("le"));
// No name is listed twice.
let mut names: Vec<&str> = SYMBOLS.iter().map(|(n, _)| *n).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(before, names.len());
}
}
-104
View File
@@ -1,104 +0,0 @@
//! Structural access to an equation tree: the arguments of a node, and every run in a tree.
use crate::math::{Arg, MAX_DEPTH, MNode, MRun};
/// Number of child arguments of a node.
pub fn child_count(n: &MNode) -> usize {
match n {
MNode::Run(_) => 0,
MNode::Frac { .. } | MNode::Func { .. } | MNode::Lim { .. } => 2,
MNode::Rad { .. } => 2,
MNode::Script { .. } | MNode::Nary { .. } => 3,
MNode::Delim { elems, .. } => elems.len(),
MNode::EqArr { rows } => rows.len(),
MNode::Matrix { rows, .. } => rows.iter().map(|r| r.len()).sum(),
MNode::Acc { .. } | MNode::Bar { .. } | MNode::BorderBox { .. } | MNode::Boxed { .. } | MNode::GroupChr { .. } | MNode::Phant { .. } => 1,
}
}
/// Child argument `i` of a node (see [`child_count`] for the numbering: fraction num 0 / den 1;
/// script base 0 / sub 1 / sup 2; radical degree 0 / base 1; n-ary sub 0 / sup 1 / operand 2;
/// function name 0 / argument 1; limit base 0 / limit 1; matrices row by row).
pub fn child(n: &MNode, i: usize) -> Option<&Arg> {
match (n, i) {
(MNode::Frac { num, .. }, 0) => Some(num),
(MNode::Frac { den, .. }, 1) => Some(den),
(MNode::Script { base, .. }, 0) => Some(base),
(MNode::Script { sub, .. }, 1) => Some(sub),
(MNode::Script { sup, .. }, 2) => Some(sup),
(MNode::Rad { deg, .. }, 0) => Some(deg),
(MNode::Rad { e, .. }, 1) => Some(e),
(MNode::Nary { sub, .. }, 0) => Some(sub),
(MNode::Nary { sup, .. }, 1) => Some(sup),
(MNode::Nary { e, .. }, 2) => Some(e),
(MNode::Delim { elems, .. }, i) => elems.get(i),
(MNode::Func { name, .. }, 0) => Some(name),
(MNode::Func { e, .. }, 1) => Some(e),
(MNode::Lim { e, .. }, 0) => Some(e),
(MNode::Lim { lim, .. }, 1) => Some(lim),
(
MNode::Acc { e, .. }
| MNode::Bar { e, .. }
| MNode::BorderBox { e, .. }
| MNode::Boxed { e }
| MNode::GroupChr { e, .. }
| MNode::Phant { e, .. },
0,
) => Some(e),
(MNode::EqArr { rows }, i) => rows.get(i),
(MNode::Matrix { rows, .. }, i) => rows.iter().flatten().nth(i),
_ => None,
}
}
pub fn child_mut(n: &mut MNode, i: usize) -> Option<&mut Arg> {
match (n, i) {
(MNode::Frac { num, .. }, 0) => Some(num),
(MNode::Frac { den, .. }, 1) => Some(den),
(MNode::Script { base, .. }, 0) => Some(base),
(MNode::Script { sub, .. }, 1) => Some(sub),
(MNode::Script { sup, .. }, 2) => Some(sup),
(MNode::Rad { deg, .. }, 0) => Some(deg),
(MNode::Rad { e, .. }, 1) => Some(e),
(MNode::Nary { sub, .. }, 0) => Some(sub),
(MNode::Nary { sup, .. }, 1) => Some(sup),
(MNode::Nary { e, .. }, 2) => Some(e),
(MNode::Delim { elems, .. }, i) => elems.get_mut(i),
(MNode::Func { name, .. }, 0) => Some(name),
(MNode::Func { e, .. }, 1) => Some(e),
(MNode::Lim { e, .. }, 0) => Some(e),
(MNode::Lim { lim, .. }, 1) => Some(lim),
(
MNode::Acc { e, .. }
| MNode::Bar { e, .. }
| MNode::BorderBox { e, .. }
| MNode::Boxed { e }
| MNode::GroupChr { e, .. }
| MNode::Phant { e, .. },
0,
) => Some(e),
(MNode::EqArr { rows }, i) => rows.get_mut(i),
(MNode::Matrix { rows, .. }, i) => rows.iter_mut().flatten().nth(i),
_ => None,
}
}
/// Call `f` on every run in the tree.
pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a.iter_mut() {
if let MNode::Run(r) = n {
f(r);
}
for c in 0..child_count(n) {
if let Some(ch) = child_mut(n, c) {
go(ch, f, depth + 1);
}
}
}
}
go(root, f, 0);
}
+2 -132
View File
@@ -1,83 +1,7 @@
use super::{Deserialize, Serialize};
/// An sRGB colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl<'de> Deserialize<'de> for Rgb {
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
struct V;
impl<'de> Visitor<'de> for V {
type Value = Rgb;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
}
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
let mut c = [0u8; 3];
for (i, x) in c.iter_mut().enumerate() {
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
}
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(Rgb(c[0], c[1], c[2]))
}
}
d.deserialize_any(V)
}
}
impl Rgb {
pub const BLACK: Rgb = Rgb(0, 0, 0);
pub const WHITE: Rgb = Rgb(255, 255, 255);
/// `RRGGBB` (no `#`).
pub fn hex(self) -> String {
format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2)
}
/// Parse `RRGGBB` or `#RRGGBB`.
pub fn parse(s: &str) -> Option<Rgb> {
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some(Rgb(p(0)?, p(2)?, p(4)?))
}
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
/// unchanged.
pub fn modified(self, name: &str, v: f64) -> Rgb {
match name {
"lumMod" => adjust_lum(self, |l| l * v),
"lumOff" => adjust_lum(self, |l| l + v),
"tint" => mix(self, 255.0, v),
"shade" => mix(self, 0.0, v),
"satMod" => {
let (h, sat, l) = to_hsl(self);
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
}
_ => self,
}
}
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
from_hsl(h, s, l)
}
/// This colour with its HSL luminance changed by `f`.
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
adjust_lum(self, f)
}
pub fn luma(self) -> f32 {
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
}
}
/// An sRGB colour: craft-math's, which equations and text share.
pub use craft_math::Rgb;
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
@@ -115,57 +39,3 @@ impl<'de> Deserialize<'de> for TextColor {
}
}
}
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
Rgb(f(c.0), f(c.1), f(c.2))
}
/// `c` with its HSL luminance changed by `f`.
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
let (h, s, l) = to_hsl(c);
from_hsl(h, s, f(l).clamp(0.0, 1.0))
}
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let l = (max + min) / 2.0;
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h / 6.0, s, l)
}
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
if s == 0.0 {
return Rgb(byte(l), byte(l), byte(l));
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let channel = |t: f64| {
let t = t.rem_euclid(1.0);
let v = if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
};
byte(v)
};
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
}
-316
View File
@@ -1,316 +0,0 @@
//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to
//! standard Unicode characters, so text set in them shows the intended symbols when the font
//! isn't installed (they are proprietary and never bundled) and exports as real characters.
//!
//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own
//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code
//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text
//! never changes: the mapping is for display, layout and plain-text output only.
//!
//! Sources (Unicode's own published data; no font file was read):
//! - Symbol: the Adobe Symbol encoding table published by Unicode,
//! <https://www.unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt> (codes it maps to the
//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU).
//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of
//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard,
//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052,
//! L2/11-344), checked against the Unicode Character Database names. The one character not
//! encoded (Wingdings 0xFF) has no entry.
//!
//! 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]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
Some(&SYMBOL)
} else if f.eq_ignore_ascii_case("wingdings") {
Some(&WINGDINGS)
} else if f.eq_ignore_ascii_case("wingdings 2") {
Some(&WINGDINGS_2)
} else if f.eq_ignore_ascii_case("wingdings 3") {
Some(&WINGDINGS_3)
} else if f.eq_ignore_ascii_case("webdings") {
Some(&WEBDINGS)
} else {
None
}
}
/// 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 {
table(family).is_some()
}
/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the
/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD).
/// `None` for other fonts, other characters and codes with no Unicode equivalent.
pub fn symbol_char(family: &str, c: char) -> Option<char> {
let t = table(family)?;
let u = u32::from(c);
let code = match u {
0xF020..=0xF0FF => u - 0xF000,
// A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD).
0xAD => return None,
0x20..=0xFF => u,
_ => return None,
};
let i = usize::try_from(code.checked_sub(0x20)?).ok()?;
t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32)
}
/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode
/// equivalent; unchanged for other fonts.
pub fn map_text(family: &str, text: &str) -> String {
if !is_symbol_font(family) {
return text.to_string();
}
text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect()
}
const SYMBOL: [u32; 224] = [
0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20
0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30
0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38
0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40
0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48
0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50
0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58
0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60
0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68
0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70
0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98
0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0
0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8
0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0
0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8
0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0
0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8
0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0
0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8
0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8
0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
const WEBDINGS: [u32; 224] = [
0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20
0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28
0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30
0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38
0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40
0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48
0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50
0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58
0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60
0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68
0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70
0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78
0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80
0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88
0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90
0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98
0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0
0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8
0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0
0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8
0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0
0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8
0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0
0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8
0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0
0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8
0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0
0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8
];
const WINGDINGS: [u32; 224] = [
0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20
0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28
0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30
0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38
0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40
0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48
0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50
0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58
0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60
0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68
0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70
0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78
0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80
0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88
0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90
0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98
0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0
0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8
0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0
0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8
0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0
0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8
0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0
0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8
0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0
0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8
0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0
0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8
];
const WINGDINGS_2: [u32; 224] = [
0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20
0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28
0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30
0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38
0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40
0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48
0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50
0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58
0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60
0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68
0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70
0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78
0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80
0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88
0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90
0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98
0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0
0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8
0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0
0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8
0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0
0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8
0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0
0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8
0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0
0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8
0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0
0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
const WINGDINGS_3: [u32; 224] = [
0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20
0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28
0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30
0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38
0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40
0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48
0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50
0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58
0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60
0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68
0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70
0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78
0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80
0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88
0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90
0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98
0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0
0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8
0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0
0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8
0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0
0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8
0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0
0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8
0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0
0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8
0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_storage_forms_map_alike() {
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
assert!(is_symbol_font(f));
for code in 0x20u32..=0xFF {
let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue };
if code == 0xAD {
assert_eq!(symbol_char(f, plain), None);
continue;
}
assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}");
}
}
}
#[test]
fn pcl6_symbol_encoding() {
for family in ["Symbol (PCL6)", " symbol (pcl6) "] {
assert!(is_symbol_font(family));
for code in 0x20..=0xFF {
let c = char::from_u32(code).unwrap();
assert_eq!(symbol_char(family, c), symbol_char("Symbol", c));
let pua = char::from_u32(0xF000 + code).unwrap();
assert_eq!(symbol_char(family, pua), symbol_char("Symbol", pua));
}
assert_eq!(symbol_char(family, '\u{F0B3}'), Some('≥'));
}
assert!(!is_symbol_font("Symbol (unknown)"));
}
#[test]
fn symbol_uses_the_adobe_symbol_encoding() {
assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α'));
assert_eq!(symbol_char("symbol", 'a'), Some('α'));
assert_eq!(symbol_char("Symbol", 'm'), Some('μ'));
assert_eq!(symbol_char("Symbol", 'W'), Some('Ω'));
assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•'));
assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞'));
assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√'));
// The radical extender is private use in the Adobe table: no equivalent.
assert_eq!(symbol_char("Symbol", '\u{F060}'), None);
}
#[test]
fn wingdings_codes() {
assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺'));
assert_eq!(symbol_char("Wingdings", 'J'), Some('☺'));
assert_eq!(symbol_char("Wingdings", 'L'), Some('☹'));
assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓'));
assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}'));
assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼'));
assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪'));
assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖'));
assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}'));
// The one character Unicode didn't encode.
assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None);
}
#[test]
fn wingdings_2_3_and_webdings_codes() {
assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}'));
assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}'));
assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None);
assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠'));
assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None);
assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}'));
assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}'));
assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}'));
}
#[test]
fn other_fonts_and_characters_are_left_alone() {
assert!(!is_symbol_font("Arial"));
assert_eq!(symbol_char("Arial", '\u{F04A}'), None);
assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None);
assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None);
assert_eq!(symbol_char("Wingdings", '\u{1F}'), None);
assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}");
assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}");
}
}
+9
View File
@@ -35,6 +35,15 @@ from those fields, keeps the rest of each part's tree in place, and writes the k
were. Part names and relationship ids are kept, so a file read and written without changes points
where it did, and writing it twice gives the same bytes. The calculation chain is dropped (Excel
rebuilds it). `XlsxKind` is the workbook part's content type (`.xlsx`, `.xlsm`, `.xltx`, `.xltm`).
Typed worksheet features (`SheetPart::features`), each mirroring its element with optional
attributes and the unnamed ones kept: sheet views (panes, selections), print setup (options,
margins, page setup, header and footer, page breaks), data validation (also read from the `x14`
extension), conditional formats (colour scales, data bars, icon sets, rule extensions kept),
hyperlinks (with their URLs), the legacy drawing. Comments (`Comments`, from the comments part)
and the note shapes of the VML drawing (`NoteShape`). A sheet that gains comments and has no VML
drawing gets one (`notes_vml`, in a shape block no kept drawing uses); an existing VML drawing is
kept as read. Extensions in `extLst` are carried through unchanged.
The parts this crate writes use the transitional namespaces; a strict file's kept parts stay as
they were. A binary workbook is not written.
+74 -6
View File
@@ -3,6 +3,7 @@
use craft_opc::{ContentTypes, PackageOut, RT_BASE, Rel, RelOut, Rels, RelsOut, relative, rels_path};
use crate::features::{Comments, NoteShape, notes_vml};
use crate::sheet::{CellValue, Row, SheetPart};
use crate::strings::StringItem;
use crate::styles::Styles;
@@ -12,6 +13,8 @@ use crate::{Error, Limits, Package};
const CT_WORKSHEET: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml";
const CT_STRINGS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml";
const CT_STYLES: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml";
const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml";
const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing";
/// The relationship type namespace of strict files: kept relationships are written with the
/// transitional one, as the parts this crate writes are.
const RT_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/";
@@ -64,11 +67,16 @@ impl XlsxKind {
}
}
/// One worksheet: its rows and the rest of its part.
/// One worksheet: its rows, the rest of its part, and its comments.
#[derive(Clone, Debug, Default)]
pub struct Worksheet {
pub rows: Vec<Row>,
pub part: SheetPart,
/// The comments part, written from here. `None` removes it.
pub comments: Option<Comments>,
/// How the comments are shown, from the legacy VML drawing. The drawing itself is kept as
/// read; these are used only to draw the comments of a sheet that has no VML drawing.
pub notes: Vec<NoteShape>,
}
/// A part carried through unchanged: drawings, charts, the theme, document properties, a VBA
@@ -131,7 +139,11 @@ impl Document {
rows.push(row);
Ok(())
}) {
Ok(part) => worksheets.push(Some(Worksheet { rows, part })),
Ok(part) => {
let comments = p.comments(&part)?;
let notes = if comments.is_some() { p.note_shapes(&part)? } else { Vec::new() };
worksheets.push(Some(Worksheet { rows, part, comments, notes }));
}
Err(Error::TooLarge(m)) => return Err(Error::TooLarge(m)),
Err(e) => {
p.warn(format!("sheet \"{}\" kept unread: {e}", entry.name));
@@ -144,6 +156,9 @@ impl Document {
for ws in worksheets.iter().flatten() {
made.push(ws.part.part.clone());
made.push(rels_path(&ws.part.part));
if ws.comments.is_some() {
made.extend(ws.part.rels.first("comments").filter(|r| !r.external).map(|r| r.target.clone()));
}
}
made.extend(workbook_rels.list.iter().filter(|r| !r.external && regenerated(r)).map(|r| r.target.clone()));
let opc = p.opc();
@@ -223,11 +238,9 @@ impl Document {
package.add("officeDocument", book_part, false);
out.rels("", &package);
}
let mut blocks = vml_blocks(&self.parts);
for (name, ws) in &sheet_parts {
out.xml_part(name, CT_WORKSHEET, ws.part.to_xml(&ws.rows));
let mut sheet_rels = RelsOut::default();
sheet_rels.items.extend(ws.part.rels.list.iter().map(kept));
out.rels(name, &sheet_rels);
write_sheet(&mut out, name, ws, &mut taken, &mut blocks);
}
if let Some(name) = &strings {
let count: usize =
@@ -252,6 +265,61 @@ impl Document {
}
}
/// A worksheet, its relationships, and its comments with a VML drawing to show them when it has
/// none. A hyperlink to a URL its relationship does not lead to gets a relationship of its own.
fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, taken: &mut Vec<String>, blocks: &mut Vec<u32>) {
let mut part = ws.part.clone();
let mut rels = RelsOut::default();
rels.items.extend(ws.part.rels.list.iter().filter(|r| ws.comments.is_some() || r.kind() != "comments").map(kept));
for h in &mut part.features.hyperlinks {
let Some(url) = &h.url else { continue };
let ok = h.rel_id.as_deref().and_then(|id| ws.part.rels.by_id(id)).is_some_and(|r| r.external && r.target == *url);
if !ok {
let id = fresh_id(&rels);
rels.items.push(RelOut { id: id.clone(), ty: format!("{RT_BASE}hyperlink"), target: url.clone(), external: true });
h.rel_id = Some(id);
}
}
if let Some(c) = &ws.comments {
let target = match ws.part.rels.first("comments").filter(|r| !r.external) {
Some(r) => r.target.clone(),
None => {
let target = fresh(taken, |n| format!("xl/comments{n}.xml"));
let id = fresh_id(&rels);
rels.items.push(RelOut { id, ty: format!("{RT_BASE}comments"), target: relative(name, &target), external: false });
target
}
};
taken.push(target.to_ascii_lowercase());
out.xml_part(&target, CT_COMMENTS, c.to_xml());
if !rels.items.iter().any(|r| r.ty.ends_with("/vmlDrawing")) && !c.comments.is_empty() {
let block = (1..).find(|b| !blocks.contains(b)).unwrap_or(1);
blocks.push(block);
let vml = fresh(taken, |n| format!("xl/drawings/vmlDrawing{n}.vml"));
taken.push(vml.to_ascii_lowercase());
let id = fresh_id(&rels);
rels.items.push(RelOut { id: id.clone(), ty: format!("{RT_BASE}vmlDrawing"), target: relative(name, &vml), external: false });
part.features.legacy_drawing = Some(id);
out.binary(&vml, CT_VML, notes_vml(&c.comments, &ws.notes, block));
}
}
out.xml_part(name, CT_WORKSHEET, part.to_xml(&ws.rows));
out.rels(name, &rels);
}
/// The shape-id blocks (`o:idmap`) the kept VML drawings use, so a new drawing takes another.
fn vml_blocks(parts: &[KeptPart]) -> Vec<u32> {
let mut used = Vec::new();
for p in parts.iter().filter(|p| p.name.to_ascii_lowercase().ends_with(".vml")) {
let text = String::from_utf8_lossy(&p.bytes);
for (_, after) in text.match_indices("idmap").map(|(i, _)| text.split_at(i)) {
let data = after.split("data=\"").nth(1).and_then(|d| d.split('"').next()).unwrap_or("");
used.extend(data.split(',').filter_map(|n| n.trim().parse::<u32>().ok()));
}
}
used
}
/// A workbook relationship to a part that [`Document::write`] makes again (or drops).
fn regenerated(r: &Rel) -> bool {
matches!(r.kind(), "sharedStrings" | "styles" | "calcChain")
+919
View File
@@ -0,0 +1,919 @@
//! The worksheet's typed features, each mirroring its SpreadsheetML element: sheet views, print
//! setup, data validation, conditional formatting, hyperlinks (ECMA-376 Part 1, 18.3), and the
//! comments part (18.7) with the legacy note shapes Excel draws comments with.
//!
//! An attribute the file may leave out is an `Option` (`None`: not written, the schema's
//! default applies), so a feature reads and writes back as it was. Attributes a type does not
//! name are kept in its `attrs` (`xr:uid` and the like).
use craft_sheet::{CellRef, MAX_COLS, MAX_ROWS, Range, col_letters};
use craft_xml::{El, Node};
use crate::strings::StringItem;
use crate::styles::Color;
use crate::write::num;
/// An attribute value type.
trait Attr: Sized {
fn parse(s: &str) -> Option<Self>;
fn spell(&self) -> String;
}
impl Attr for bool {
fn parse(s: &str) -> Option<bool> {
craft_xml::value::to_bool(s)
}
fn spell(&self) -> String {
if *self { "1" } else { "0" }.into()
}
}
impl Attr for u32 {
fn parse(s: &str) -> Option<u32> {
craft_xml::value::to_u32(s)
}
fn spell(&self) -> String {
self.to_string()
}
}
impl Attr for i32 {
fn parse(s: &str) -> Option<i32> {
craft_xml::value::to_i32(s)
}
fn spell(&self) -> String {
self.to_string()
}
}
impl Attr for f64 {
fn parse(s: &str) -> Option<f64> {
craft_xml::value::to_f64(s)
}
fn spell(&self) -> String {
num(*self)
}
}
impl Attr for String {
fn parse(s: &str) -> Option<String> {
Some(s.to_string())
}
fn spell(&self) -> String {
self.clone()
}
}
/// The attributes of `e` not in `named`. A container's `count` (written from the items) and
/// the `ref` and `sqref` each type reads into a field of its own are left out.
fn rest(e: &El, named: &[&str]) -> Vec<(String, String)> {
e.attrs.iter().filter(|(k, _)| !matches!(k.as_str(), "count" | "ref" | "sqref") && !named.contains(&k.as_str())).cloned().collect()
}
/// A struct whose `Option` fields are the element's attributes (`field: type = "xmlName"`), with
/// further fields after `+` that the element's own reader fills, and `attrs` for the rest.
macro_rules! element {
(
$(#[$m:meta])* pub struct $name:ident {
$($(#[$fm:meta])* $f:ident: $t:ty = $x:literal,)*
}
$(+ { $($(#[$em:meta])* $e:ident: $et:ty,)* })?
) => {
$(#[$m])*
#[derive(Clone, Debug, Default, PartialEq)]
pub struct $name {
$($(#[$fm])* pub $f: Option<$t>,)*
$($($(#[$em])* pub $e: $et,)*)?
/// The other attributes as written.
pub attrs: Vec<(String, String)>,
}
impl $name {
const NAMED: &'static [&'static str] = &[$($x),*];
#[allow(clippy::needless_update)]
fn read_attrs(el: &El) -> $name {
$name { $($f: el.attr($x).and_then(<$t as Attr>::parse),)* attrs: rest(el, Self::NAMED), ..$name::default() }
}
fn write_attrs(&self, el: &mut El) {
$(if let Some(v) = &self.$f { el.set_attr($x, v.spell()); })*
el.attrs.extend(self.attrs.iter().cloned());
}
}
};
}
/// `sqref`: ranges separated by spaces. Unreadable ranges are dropped.
pub fn parse_sqref(s: &str) -> Vec<Range> {
s.split_whitespace().filter_map(Range::parse).collect()
}
/// A range as a reference writes it: whole columns as `A:C`, whole rows as `1:3`.
pub fn range_ref(r: &Range) -> String {
let full_cols = r.first.row == 0 && r.last.row == MAX_ROWS - 1;
let full_rows = r.first.col == 0 && r.last.col == MAX_COLS - 1;
match (full_cols, full_rows) {
(true, false) => format!("{}:{}", col_letters(r.first.col), col_letters(r.last.col)),
(false, true) => format!("{}:{}", r.first.row + 1, r.last.row + 1),
_ => r.a1(),
}
}
fn sqref(ranges: &[Range]) -> String {
ranges.iter().map(range_ref).collect::<Vec<_>>().join(" ")
}
fn kids(e: &El, except: &[&str]) -> Vec<El> {
e.elements().filter(|c| !except.contains(&c.name.as_str())).cloned().collect()
}
// ------------------------------------------------------------------------------- sheet views
element! {
/// `<pane>`: the split or frozen panes of a view.
pub struct Pane {
x_split: f64 = "xSplit",
y_split: f64 = "ySplit",
top_left_cell: String = "topLeftCell",
active_pane: String = "activePane",
state: String = "state",
}
}
element! {
/// `<selection>`.
pub struct Selection {
pane: String = "pane",
active_cell: String = "activeCell",
active_cell_id: u32 = "activeCellId",
}
+ {
sqref: Vec<Range>,
}
}
element! {
/// `<sheetView>` (18.3.1.87).
pub struct SheetView {
window_protection: bool = "windowProtection",
show_formulas: bool = "showFormulas",
show_grid_lines: bool = "showGridLines",
show_row_col_headers: bool = "showRowColHeaders",
show_zeros: bool = "showZeros",
right_to_left: bool = "rightToLeft",
tab_selected: bool = "tabSelected",
show_ruler: bool = "showRuler",
show_outline_symbols: bool = "showOutlineSymbols",
default_grid_color: bool = "defaultGridColor",
show_white_space: bool = "showWhiteSpace",
/// `normal`, `pageBreakPreview`, `pageLayout`.
view: String = "view",
top_left_cell: String = "topLeftCell",
color_id: u32 = "colorId",
zoom_scale: u32 = "zoomScale",
zoom_scale_normal: u32 = "zoomScaleNormal",
zoom_scale_sheet_layout_view: u32 = "zoomScaleSheetLayoutView",
zoom_scale_page_layout_view: u32 = "zoomScalePageLayoutView",
workbook_view_id: u32 = "workbookViewId",
}
+ {
pane: Option<Pane>,
selections: Vec<Selection>,
/// `pivotSelection` and `extLst`, as written.
other: Vec<El>,
}
}
impl SheetView {
pub fn read(e: &El) -> SheetView {
let selection = |s: &El| Selection { sqref: s.attr("sqref").map(parse_sqref).unwrap_or_default(), ..Selection::read_attrs(s) };
SheetView {
pane: e.child("pane").map(Pane::read_attrs),
selections: e.children("selection").map(selection).collect(),
other: kids(e, &["pane", "selection"]),
..SheetView::read_attrs(e)
}
}
pub fn to_el(&self) -> El {
let mut e = El::new("sheetView");
self.write_attrs(&mut e);
if let Some(p) = &self.pane {
let mut pe = El::new("pane");
p.write_attrs(&mut pe);
e.push(pe);
}
for s in &self.selections {
let mut se = El::new("selection");
s.write_attrs(&mut se);
if !s.sqref.is_empty() {
se.set_attr("sqref", sqref(&s.sqref));
}
e.push(se);
}
e.children.extend(self.other.iter().cloned().map(Node::El));
e
}
}
// ------------------------------------------------------------------------------- printing
element! {
/// `<printOptions>`.
pub struct PrintOptions {
horizontal_centered: bool = "horizontalCentered",
vertical_centered: bool = "verticalCentered",
headings: bool = "headings",
grid_lines: bool = "gridLines",
grid_lines_set: bool = "gridLinesSet",
}
}
element! {
/// `<pageMargins>`, inches.
pub struct PageMargins {
left: f64 = "left",
right: f64 = "right",
top: f64 = "top",
bottom: f64 = "bottom",
header: f64 = "header",
footer: f64 = "footer",
}
}
element! {
/// `<pageSetup>`.
pub struct PageSetup {
paper_size: u32 = "paperSize",
/// With its unit (`297mm`).
paper_height: String = "paperHeight",
paper_width: String = "paperWidth",
scale: u32 = "scale",
first_page_number: u32 = "firstPageNumber",
fit_to_width: u32 = "fitToWidth",
fit_to_height: u32 = "fitToHeight",
/// `downThenOver`, `overThenDown`.
page_order: String = "pageOrder",
/// `default`, `portrait`, `landscape`.
orientation: String = "orientation",
use_printer_defaults: bool = "usePrinterDefaults",
black_and_white: bool = "blackAndWhite",
draft: bool = "draft",
/// `none`, `asDisplayed`, `atEnd`.
cell_comments: String = "cellComments",
use_first_page_number: bool = "useFirstPageNumber",
/// `displayed`, `blank`, `dash`, `NA`.
errors: String = "errors",
horizontal_dpi: u32 = "horizontalDpi",
vertical_dpi: u32 = "verticalDpi",
copies: u32 = "copies",
/// The printer settings part.
rel_id: String = "r:id",
}
}
element! {
/// `<headerFooter>`: the header and footer texts with their `&` codes as written.
pub struct HeaderFooter {
different_odd_even: bool = "differentOddEven",
different_first: bool = "differentFirst",
scale_with_doc: bool = "scaleWithDoc",
align_with_margins: bool = "alignWithMargins",
}
+ {
odd_header: Option<String>,
odd_footer: Option<String>,
even_header: Option<String>,
even_footer: Option<String>,
first_header: Option<String>,
first_footer: Option<String>,
}
}
const HEADER_FOOTER: [&str; 6] = ["oddHeader", "oddFooter", "evenHeader", "evenFooter", "firstHeader", "firstFooter"];
impl HeaderFooter {
fn texts(&self) -> [&Option<String>; 6] {
[&self.odd_header, &self.odd_footer, &self.even_header, &self.even_footer, &self.first_header, &self.first_footer]
}
pub fn read(e: &El) -> HeaderFooter {
let t = |n: &str| e.child(n).map(|c| c.text().into_owned());
HeaderFooter {
odd_header: t("oddHeader"),
odd_footer: t("oddFooter"),
even_header: t("evenHeader"),
even_footer: t("evenFooter"),
first_header: t("firstHeader"),
first_footer: t("firstFooter"),
..HeaderFooter::read_attrs(e)
}
}
pub fn to_el(&self) -> El {
let mut e = El::new("headerFooter");
self.write_attrs(&mut e);
for (name, text) in HEADER_FOOTER.iter().zip(self.texts()) {
if let Some(t) = text {
let mut c = El::new(*name);
if t.starts_with(' ') || t.ends_with(' ') {
c.set_attr("xml:space", "preserve");
}
e.push(if t.is_empty() { c } else { c.with_text(t.as_str()) });
}
}
e
}
}
element! {
/// `<brk>`: a page break before row or column `id` (zero-based is `id - 1`: the break
/// follows that many rows or columns).
pub struct Break {
id: u32 = "id",
min: u32 = "min",
max: u32 = "max",
/// Inserted by hand.
man: bool = "man",
/// Inserted for a pivot table.
pt: bool = "pt",
}
}
fn breaks_el(name: &str, breaks: &[Break]) -> Option<El> {
if breaks.is_empty() {
return None;
}
let manual = breaks.iter().filter(|b| b.man == Some(true)).count();
let mut e = El::new(name).with_attr("count", breaks.len()).with_attr("manualBreakCount", manual);
for b in breaks {
let mut be = El::new("brk");
b.write_attrs(&mut be);
e.push(be);
}
Some(e)
}
/// How a worksheet prints.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Print {
pub options: Option<PrintOptions>,
pub margins: Option<PageMargins>,
pub setup: Option<PageSetup>,
pub header_footer: Option<HeaderFooter>,
pub row_breaks: Vec<Break>,
pub col_breaks: Vec<Break>,
}
// ------------------------------------------------------------------------------- data validation
element! {
/// `<dataValidation>` (18.3.1.32). The formulas are as the file spells them, without `=`.
pub struct DataValidation {
/// `none`, `whole`, `decimal`, `list`, `date`, `time`, `textLength`, `custom`.
kind: String = "type",
/// `stop`, `warning`, `information`.
error_style: String = "errorStyle",
ime_mode: String = "imeMode",
operator: String = "operator",
allow_blank: bool = "allowBlank",
/// Set: the in-cell list arrow is *hidden*.
show_drop_down: bool = "showDropDown",
show_input_message: bool = "showInputMessage",
show_error_message: bool = "showErrorMessage",
error_title: String = "errorTitle",
error: String = "error",
prompt_title: String = "promptTitle",
prompt: String = "prompt",
}
+ {
sqref: Vec<Range>,
formula1: Option<String>,
formula2: Option<String>,
}
}
impl DataValidation {
/// A `dataValidation` of the main namespace, or of an `x14:dataValidations` extension (its
/// formulas in `xm:f` and its ranges in `xm:sqref`).
pub fn read(v: &El) -> DataValidation {
let formula = |n: &str| {
let f = v.child(n).or_else(|| v.child(format!("x14:{n}").as_str()))?;
Some(f.child("xm:f").map_or_else(|| f.text(), |x| x.text()).trim().to_string())
};
let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("xm:sqref").map(|s| s.text().into_owned()));
DataValidation {
sqref: sq.as_deref().map(parse_sqref).unwrap_or_default(),
formula1: formula("formula1"),
formula2: formula("formula2"),
..DataValidation::read_attrs(v)
}
}
/// The main-namespace element.
pub fn to_el(&self) -> El {
let mut e = El::new("dataValidation");
self.write_attrs(&mut e);
e.set_attr("sqref", sqref(&self.sqref));
for (n, f) in [("formula1", &self.formula1), ("formula2", &self.formula2)] {
if let Some(f) = f {
e.push(El::new(n).with_text(f.as_str()));
}
}
e
}
}
element! {
/// `<dataValidations>`.
pub struct DataValidations {
disable_prompts: bool = "disablePrompts",
x_window: u32 = "xWindow",
y_window: u32 = "yWindow",
}
+ {
items: Vec<DataValidation>,
}
}
// ------------------------------------------------------------------------------- conditional formats
element! {
/// `<cfvo>`: a threshold of a colour scale, data bar or icon set.
pub struct Cfvo {
/// `num`, `percent`, `max`, `min`, `formula`, `percentile`.
kind: String = "type",
val: String = "val",
gte: bool = "gte",
}
}
fn cfvos(e: &El) -> Vec<Cfvo> {
e.children("cfvo").map(Cfvo::read_attrs).collect()
}
fn push_cfvos(e: &mut El, cfvos: &[Cfvo]) {
for c in cfvos {
let mut ce = El::new("cfvo");
c.write_attrs(&mut ce);
e.push(ce);
}
}
/// `<colorScale>`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ColorScale {
pub cfvos: Vec<Cfvo>,
pub colors: Vec<Color>,
}
element! {
/// `<dataBar>`.
pub struct DataBar {
min_length: u32 = "minLength",
max_length: u32 = "maxLength",
show_value: bool = "showValue",
}
+ {
cfvos: Vec<Cfvo>,
color: Option<Color>,
}
}
element! {
/// `<iconSet>`.
pub struct IconSet {
icon_set: String = "iconSet",
show_value: bool = "showValue",
percent: bool = "percent",
reverse: bool = "reverse",
}
+ {
cfvos: Vec<Cfvo>,
}
}
element! {
/// `<cfRule>` (18.3.1.10).
pub struct CfRule {
/// `expression`, `cellIs`, `colorScale`, `dataBar`, `iconSet`, `top10`, `uniqueValues`,
/// `duplicateValues`, `containsText`, ..., `aboveAverage`.
kind: String = "type",
/// An index into [`crate::Styles::dxfs`].
dxf_id: u32 = "dxfId",
priority: u32 = "priority",
stop_if_true: bool = "stopIfTrue",
above_average: bool = "aboveAverage",
percent: bool = "percent",
bottom: bool = "bottom",
operator: String = "operator",
text: String = "text",
time_period: String = "timePeriod",
rank: u32 = "rank",
std_dev: i32 = "stdDev",
equal_average: bool = "equalAverage",
}
+ {
/// `<formula>` texts, as written.
formulas: Vec<String>,
color_scale: Option<ColorScale>,
data_bar: Option<DataBar>,
icon_set: Option<IconSet>,
/// The rule's `extLst` (the id that links a data bar to its Excel 2010 extension).
ext: Option<El>,
}
}
impl CfRule {
pub fn read(r: &El) -> CfRule {
CfRule {
formulas: r.children("formula").map(|f| f.text().into_owned()).collect(),
color_scale: r.child("colorScale").map(|c| ColorScale { cfvos: cfvos(c), colors: c.children("color").map(Color::read).collect() }),
data_bar: r.child("dataBar").map(|d| DataBar { cfvos: cfvos(d), color: d.child("color").map(Color::read), ..DataBar::read_attrs(d) }),
icon_set: r.child("iconSet").map(|i| IconSet { cfvos: cfvos(i), ..IconSet::read_attrs(i) }),
ext: r.child("extLst").cloned(),
..CfRule::read_attrs(r)
}
}
pub fn to_el(&self) -> El {
let mut e = El::new("cfRule");
self.write_attrs(&mut e);
for f in &self.formulas {
e.push(El::new("formula").with_text(f.as_str()));
}
if let Some(c) = &self.color_scale {
let mut ce = El::new("colorScale");
push_cfvos(&mut ce, &c.cfvos);
c.colors.iter().for_each(|col| ce.push(col.to_el("color")));
e.push(ce);
}
if let Some(d) = &self.data_bar {
let mut de = El::new("dataBar");
d.write_attrs(&mut de);
push_cfvos(&mut de, &d.cfvos);
if let Some(c) = &d.color {
de.push(c.to_el("color"));
}
e.push(de);
}
if let Some(i) = &self.icon_set {
let mut ie = El::new("iconSet");
i.write_attrs(&mut ie);
push_cfvos(&mut ie, &i.cfvos);
e.push(ie);
}
if let Some(x) = &self.ext {
e.push(x.clone());
}
e
}
}
element! {
/// `<conditionalFormatting>`: rules over ranges.
pub struct ConditionalFormatting {
pivot: bool = "pivot",
}
+ {
sqref: Vec<Range>,
rules: Vec<CfRule>,
ext: Option<El>,
}
}
impl ConditionalFormatting {
pub fn read(e: &El) -> ConditionalFormatting {
ConditionalFormatting {
sqref: e.attr("sqref").map(parse_sqref).unwrap_or_default(),
rules: e.children("cfRule").map(CfRule::read).collect(),
ext: e.child("extLst").cloned(),
..ConditionalFormatting::read_attrs(e)
}
}
pub fn to_el(&self) -> El {
let mut e = El::new("conditionalFormatting");
self.write_attrs(&mut e);
e.set_attr("sqref", sqref(&self.sqref));
self.rules.iter().for_each(|r| e.push(r.to_el()));
if let Some(x) = &self.ext {
e.push(x.clone());
}
e
}
}
// ------------------------------------------------------------------------------- hyperlinks
element! {
/// `<hyperlink>`: to a URL through a relationship, or to a place in the workbook.
pub struct Hyperlink {
rel_id: String = "r:id",
/// A place in the workbook (`Sheet2!A1`, a defined name).
location: String = "location",
tooltip: String = "tooltip",
display: String = "display",
}
+ {
range: Range,
/// The relationship's target when it points outside the package, read from the sheet's
/// relationships. On writing, a link whose `rel_id` does not lead to this URL gets a
/// relationship of its own.
url: Option<String>,
}
}
impl Hyperlink {
fn read(e: &El, rels: &craft_opc::Rels) -> Option<Hyperlink> {
let range = Range::parse(e.attr("ref")?)?;
let h = Hyperlink::read_attrs(e);
let url = h.rel_id.as_deref().and_then(|id| rels.by_id(id)).filter(|r| r.external).map(|r| r.target.clone());
Some(Hyperlink { range, url, ..h })
}
pub fn to_el(&self) -> El {
let mut e = El::new("hyperlink").with_attr("ref", range_ref(&self.range));
self.write_attrs(&mut e);
e
}
}
// ------------------------------------------------------------------------------- the lift
/// The typed features of a worksheet, lifted from its tree. Each one listed here is written from
/// these fields; its element in [`crate::SheetPart::root`] is not.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Features {
pub views: Vec<SheetView>,
pub print: Print,
pub validations: Option<DataValidations>,
pub conditional_formats: Vec<ConditionalFormatting>,
pub hyperlinks: Vec<Hyperlink>,
/// `legacyDrawing/@r:id`: the VML part the sheet's notes and form controls are drawn in.
pub legacy_drawing: Option<String>,
}
impl Features {
pub(crate) fn read(root: &El, rels: &craft_opc::Rels) -> Features {
let attrs_of = |n: &str| root.child(n);
Features {
views: root.child("sheetViews").map(|v| v.children("sheetView").map(SheetView::read).collect()).unwrap_or_default(),
print: Print {
options: attrs_of("printOptions").map(PrintOptions::read_attrs),
margins: attrs_of("pageMargins").map(PageMargins::read_attrs),
setup: attrs_of("pageSetup").map(PageSetup::read_attrs),
header_footer: attrs_of("headerFooter").map(HeaderFooter::read),
row_breaks: root.child("rowBreaks").map(|b| b.children("brk").map(Break::read_attrs).collect()).unwrap_or_default(),
col_breaks: root.child("colBreaks").map(|b| b.children("brk").map(Break::read_attrs).collect()).unwrap_or_default(),
},
validations: root.child("dataValidations").map(|d| DataValidations {
items: d.children("dataValidation").map(DataValidation::read).collect(),
..DataValidations::read_attrs(d)
}),
conditional_formats: root.children("conditionalFormatting").map(ConditionalFormatting::read).collect(),
hyperlinks: root
.child("hyperlinks")
.map(|h| h.children("hyperlink").filter_map(|e| Hyperlink::read(e, rels)).collect())
.unwrap_or_default(),
legacy_drawing: root.child("legacyDrawing").and_then(|l| l.attr_or_plain("r:id")).map(str::to_string),
}
}
/// The elements these fields own, for [`crate::write::assemble`]. Conditional formats are
/// one element per block: the first block takes the place of the file's first, the others
/// follow it.
pub(crate) fn owned(&self) -> Vec<(&'static str, Vec<El>)> {
let mut views = El::new("sheetViews");
self.views.iter().for_each(|v| views.push(v.to_el()));
let el = |name: &str, write: &dyn Fn(&mut El)| {
let mut e = El::new(name);
write(&mut e);
e
};
let validations = self.validations.as_ref().filter(|v| !v.items.is_empty()).map(|v| {
let mut e = el("dataValidations", &|e| v.write_attrs(e));
e.attrs.insert(0, ("count".into(), v.items.len().to_string()));
v.items.iter().for_each(|i| e.push(i.to_el()));
e
});
let mut links = El::new("hyperlinks");
self.hyperlinks.iter().for_each(|h| links.push(h.to_el()));
let p = &self.print;
vec![
("sheetViews", Vec::from_iter((!self.views.is_empty()).then_some(views))),
("conditionalFormatting", self.conditional_formats.iter().map(ConditionalFormatting::to_el).collect()),
("dataValidations", Vec::from_iter(validations)),
("hyperlinks", Vec::from_iter((!self.hyperlinks.is_empty()).then_some(links))),
("printOptions", Vec::from_iter(p.options.as_ref().map(|o| el("printOptions", &|e| o.write_attrs(e))))),
("pageMargins", Vec::from_iter(p.margins.as_ref().map(|m| el("pageMargins", &|e| m.write_attrs(e))))),
("pageSetup", Vec::from_iter(p.setup.as_ref().map(|s| el("pageSetup", &|e| s.write_attrs(e))))),
("headerFooter", Vec::from_iter(p.header_footer.as_ref().map(HeaderFooter::to_el))),
("rowBreaks", Vec::from_iter(breaks_el("rowBreaks", &p.row_breaks))),
("colBreaks", Vec::from_iter(breaks_el("colBreaks", &p.col_breaks))),
("legacyDrawing", Vec::from_iter(self.legacy_drawing.as_ref().map(|id| El::new("legacyDrawing").with_attr("r:id", id)))),
]
}
}
// ------------------------------------------------------------------------------- comments
element! {
/// `<comment>`: a note on a cell.
pub struct Comment {
author_id: u32 = "authorId",
guid: String = "guid",
shape_id: u32 = "shapeId",
}
+ {
at: CellRef,
text: StringItem,
/// `commentPr`, as written.
props: Option<El>,
}
}
/// The comments part of a worksheet (`xl/commentsN.xml`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Comments {
pub authors: Vec<String>,
pub comments: Vec<Comment>,
/// The whole part, for what is not lifted out (extensions).
pub root: El,
}
impl Comments {
/// Comments with no readable `ref` are dropped.
pub fn read(root: El) -> Comments {
let authors = root.child("authors").map(|a| a.children("author").map(|a| a.text().into_owned()).collect()).unwrap_or_default();
let comments = root
.child("commentList")
.map(|l| {
l.children("comment")
.filter_map(|c| {
Some(Comment {
at: CellRef::parse(c.attr("ref")?)?,
text: c.child("text").map(StringItem::read).unwrap_or_default(),
props: c.child("commentPr").cloned(),
..Comment::read_attrs(c)
})
})
.collect()
})
.unwrap_or_default();
Comments { authors, comments, root }
}
/// The author of `c`.
pub fn author(&self, c: &Comment) -> Option<&str> {
self.authors.get(c.author_id? as usize).map(String::as_str)
}
pub fn to_xml(&self) -> Vec<u8> {
let root = if self.root.name.is_empty() { El::new("comments") } else { self.root.clone() };
let mut authors = El::new("authors");
self.authors.iter().for_each(|a| authors.push(El::new("author").with_text(a.as_str())));
let mut list = El::new("commentList");
for c in &self.comments {
let mut e = El::new("comment").with_attr("ref", c.at.a1());
c.write_attrs(&mut e);
e.push(c.text.to_el("text"));
if let Some(p) = &c.props {
e.push(p.clone());
}
list.push(e);
}
let mut out =
crate::write::assemble(&root, &["authors", "commentList", "extLst"], vec![("authors", Some(authors)), ("commentList", Some(list))]);
crate::ns::declare(&mut out);
let mut w = craft_xml::Writer::document();
w.tree(&out);
w.into_bytes()
}
}
/// How a note's box is drawn, from its legacy VML shape.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NoteShape {
pub at: CellRef,
/// Shown all the time (`<x:Visible/>`), not only on hover.
pub visible: bool,
/// `x:Anchor`: left column, offset px, top row, offset px, right column, offset px, bottom
/// row, offset px.
pub anchor: Option<[u32; 8]>,
/// The shape's width and height in points, from its style.
pub size_pt: Option<(f64, f64)>,
}
impl NoteShape {
/// The note shapes of a VML drawing read with [`crate::ns::CANONICAL`]. Shapes may sit in
/// groups; other shapes (form controls) are skipped.
pub fn read_all(vml: &El) -> Vec<NoteShape> {
let mut out = Vec::new();
for shape in vml.descendants().filter(|e| e.name == "v:shape") {
let Some(cd) = shape.child("x:ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue };
let n = |k: &str| cd.child(k).and_then(|v| v.text().trim().parse::<u32>().ok());
let (Some(row), Some(col)) = (n("x:Row"), n("x:Column")) else { continue };
if row >= MAX_ROWS || col >= MAX_COLS {
continue;
}
let anchor = cd.child("x:Anchor").and_then(|a| {
let v: Vec<u32> = a.text().split(',').map(|p| p.trim().parse::<u32>().ok()).collect::<Option<_>>()?;
<[u32; 8]>::try_from(v).ok()
});
let style = shape.attr("style").unwrap_or("");
let pt = |key: &str| {
style
.split(';')
.filter_map(|d| d.split_once(':'))
.find(|(k, _)| k.trim().eq_ignore_ascii_case(key))
.and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6))
};
let size_pt = pt("width").zip(pt("height"));
out.push(NoteShape { at: CellRef::new(row, col), visible: cd.child("x:Visible").is_some(), anchor, size_pt });
}
out
}
}
/// A legacy VML drawing that shows `comments` as notes, Excel's way, with shape ids from block
/// `block` (`o:idmap`: ids `1024 * block + 1` on). A note with a shape in `shapes` keeps its box
/// and visibility; the others get Excel's default box, two columns right of the cell.
pub fn notes_vml(comments: &[Comment], shapes: &[NoteShape], block: u32) -> Vec<u8> {
let mut s = format!(
"<xml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><o:shapelayout v:ext=\"edit\"><o:idmap v:ext=\"edit\" data=\"{block}\"/></o:shapelayout><v:shapetype id=\"_x0000_t202\" coordsize=\"21600,21600\" o:spt=\"202\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/><v:path gradientshapeok=\"t\" o:connecttype=\"rect\"/></v:shapetype>"
);
for (i, c) in comments.iter().enumerate() {
let shape = shapes.iter().find(|s| s.at == c.at);
let id = u64::from(block) * 1024 + 1 + i as u64;
let visible = shape.is_some_and(|s| s.visible);
let clamp = |v: i64, max: u32| v.clamp(0, i64::from(max) - 1);
let (row, col) = (i64::from(c.at.row), i64::from(c.at.col));
let anchor = shape.and_then(|s| s.anchor).unwrap_or_else(|| {
let a = [clamp(col + 1, MAX_COLS), 15, clamp(row, MAX_ROWS), 2, clamp(col + 3, MAX_COLS), 15, clamp(row + 4, MAX_ROWS), 16];
a.map(|v| u32::try_from(v).unwrap_or(0))
});
let (w, h) = shape.and_then(|s| s.size_pt).unwrap_or((108.0, 59.25));
let anchor = anchor.map(|v| v.to_string()).join(", ");
s.push_str(&format!(
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t202\" style=\"position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:{}pt;height:{}pt;z-index:{};visibility:{}\" fillcolor=\"#ffffe1\" o:insetmode=\"auto\"><v:fill color2=\"#ffffe1\"/><v:shadow on=\"t\" color=\"black\" obscured=\"t\"/><v:path o:connecttype=\"none\"/><v:textbox style=\"mso-direction-alt:auto\"><div style=\"text-align:left\"></div></v:textbox><x:ClientData ObjectType=\"Note\"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>{anchor}</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>{}</x:Row><x:Column>{}</x:Column>{}</x:ClientData></v:shape>",
num(w),
num(h),
i + 1,
if visible { "visible" } else { "hidden" },
c.at.row,
c.at.col,
if visible { "<x:Visible/>" } else { "" }
));
}
s.push_str("</xml>");
s.into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
fn read(xml: &str) -> El {
craft_xml::Options::canonical(&crate::ns::CANONICAL).parse(xml.as_bytes()).unwrap_or_default()
}
#[test]
fn rules_with_scales_bars_and_icons_write_back() {
let x = r#"<conditionalFormatting xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" sqref="A1:A9 C1"><cfRule type="colorScale" priority="3"><colorScale><cfvo type="min"/><cfvo type="percentile" val="50"/><cfvo type="max"/><color rgb="FFF8696B"/><color theme="4" tint="0.5"/><color rgb="FF63BE7B"/></colorScale></cfRule><cfRule type="dataBar" priority="2"><dataBar showValue="0"><cfvo type="min"/><cfvo type="max"/><color rgb="FF638EC6"/></dataBar><extLst><ext uri="{B025F937-C7B1-47D3-B67F-A62EFF666E3E}"><x14:id>{1}</x14:id></ext></extLst></cfRule><cfRule type="iconSet" priority="1"><iconSet iconSet="3Arrows" reverse="1"><cfvo type="percent" val="0"/><cfvo type="percent" val="33" gte="0"/></iconSet></cfRule><cfRule type="top10" dxfId="1" priority="4" rank="5" percent="1" bottom="1" stopIfTrue="1"/></conditionalFormatting>"#;
let cf = ConditionalFormatting::read(&read(x));
assert_eq!(cf.sqref.len(), 2);
let [scale, bar, icons, top] = &cf.rules[..] else { panic!("{:?}", cf.rules) };
let scale = scale.color_scale.as_ref().unwrap();
assert_eq!((scale.cfvos[1].kind.as_deref(), scale.cfvos[1].val.as_deref(), scale.colors[1].tint), (Some("percentile"), Some("50"), 0.5));
assert_eq!(bar.data_bar.as_ref().map(|d| d.show_value), Some(Some(false)));
assert!(bar.ext.as_ref().is_some_and(|e| e.find("x14:id").is_some()));
assert_eq!(
icons.icon_set.as_ref().map(|i| (i.icon_set.as_deref(), i.reverse, i.cfvos[1].gte)),
Some((Some("3Arrows"), Some(true), Some(false)))
);
assert_eq!((top.rank, top.percent, top.bottom, top.stop_if_true, top.dxf_id), (Some(5), Some(true), Some(true), Some(true), Some(1)));
let mut written = cf.to_el();
crate::ns::declare(&mut written);
assert_eq!(ConditionalFormatting::read(&read(&written.to_xml())), cf);
}
#[test]
fn extension_validations_read_into_the_same_type() {
let x = r#"<x14:dataValidation xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" xmlns:xm="http://schemas.microsoft.com/office/excel/2006/main" type="list" allowBlank="1"><x14:formula1><xm:f>Other!$A$1:$A$3</xm:f></x14:formula1><xm:sqref>G1:G4</xm:sqref></x14:dataValidation>"#;
let v = DataValidation::read(&read(x));
assert_eq!(
(v.kind.as_deref(), v.formula1.as_deref(), v.sqref.iter().map(range_ref).collect::<Vec<_>>()),
(Some("list"), Some("Other!$A$1:$A$3"), vec!["G1:G4".to_string()])
);
assert_eq!(
v.to_el().to_xml(),
r#"<dataValidation type="list" allowBlank="1" sqref="G1:G4"><formula1>Other!$A$1:$A$3</formula1></dataValidation>"#
);
}
#[test]
fn references() {
for r in ["A1", "B2:C9", "A:C", "3:5", "A1:XFD1048576"] {
assert_eq!(range_ref(&Range::parse(r).unwrap()), r);
}
assert_eq!(parse_sqref(" A1 B2:C3 bad 4:4").len(), 3);
}
}
+5
View File
@@ -50,6 +50,7 @@
//! ```
mod document;
mod features;
pub mod ns;
mod package;
mod sheet;
@@ -66,6 +67,10 @@ pub use craft_sheet::{
builtin_format, builtin_format_id, quote_sheet, sheet_needs_quotes,
};
pub use document::{Document, KeptPart, Worksheet, XlsxKind};
pub use features::{
Break, CfRule, Cfvo, ColorScale, Comment, Comments, ConditionalFormatting, DataBar, DataValidation, DataValidations, Features, HeaderFooter,
Hyperlink, IconSet, NoteShape, PageMargins, PageSetup, Pane, Print, PrintOptions, Selection, SheetView, notes_vml, parse_sqref, range_ref,
};
pub use package::{Package, is_xlsb};
pub use sheet::{Cell, CellFormula, CellValue, Column, FormulaKind, Row, SheetPart, iso_date_serial};
pub use strings::{PhoneticProps, PhoneticRun, StringItem, TextRun, decode_xstring, encode_xstring, rich_text, t_el};
+13
View File
@@ -3,6 +3,7 @@
use craft_opc::{ContentTypes, LazyPackage, Rels};
use craft_xml::{El, Options, UnknownEntity};
use crate::features::{Comments, NoteShape};
use crate::sheet::{Row, RowReader, SheetPart};
use crate::strings::StringItem;
use crate::styles::Styles;
@@ -257,6 +258,18 @@ impl<'a> Package<'a> {
Ok(sheet)
}
/// The comments of a worksheet read by [`Package::sheet`]; `None` when it has none.
pub fn comments(&mut self, sheet: &SheetPart) -> Result<Option<Comments>, Error> {
let Some(rel) = sheet.rels.first("comments").filter(|r| !r.external) else { return Ok(None) };
Ok(self.optional_xml(&rel.target.clone())?.map(Comments::read))
}
/// The note shapes of a worksheet's legacy VML drawing: how its comments are shown.
pub fn note_shapes(&mut self, sheet: &SheetPart) -> Result<Vec<NoteShape>, Error> {
let Some(rel) = sheet.rels.first("vmlDrawing").filter(|r| !r.external) else { return Ok(Vec::new()) };
Ok(self.optional_xml(&rel.target.clone())?.map(|v| NoteShape::read_all(&v)).unwrap_or_default())
}
fn required(&mut self, part: &str) -> Result<Vec<u8>, Error> {
self.opc.read(part)?.ok_or_else(|| Error::Invalid(format!("missing part {part}")))
}
+13 -7
View File
@@ -7,6 +7,7 @@ use craft_opc::Rels;
use craft_sheet::{CellError, CellRef, MAX_COLS, MAX_ROWS, Range, Value};
use craft_xml::{El, Writer};
use crate::features::Features;
use crate::strings::{StringItem, decode_xstring, encode_xstring};
use crate::write::{assemble, list, num, write_streamed};
@@ -190,6 +191,9 @@ pub struct SheetPart {
/// Why reading stopped early, when the part is damaged; the rows before the damage were
/// handed out.
pub damaged: Option<String>,
/// Views, print setup, data validation, conditional formats, hyperlinks: written from here,
/// not from [`SheetPart::root`].
pub features: Features,
}
impl SheetPart {
@@ -213,6 +217,7 @@ impl SheetPart {
});
}
}
self.features = Features::read(&self.root, &self.rels);
if let Some(m) = self.root.child("mergeCells") {
self.merges.extend(m.children("mergeCell").filter_map(|m| m.attr("ref").and_then(Range::parse)).filter(|r| !r.is_single()));
}
@@ -220,8 +225,8 @@ impl SheetPart {
}
impl SheetPart {
/// The worksheet part with `rows` as its `sheetData`. The dimension, the columns and the
/// merged cells come from the fields; everything else in [`SheetPart::root`] is written as
/// The worksheet part with `rows` as its `sheetData`. The dimension, the columns, the
/// merged cells and [`SheetPart::features`] come from the fields; everything else in [`SheetPart::root`] is written as
/// kept, in place.
pub fn to_xml(&self, rows: &[Row]) -> Vec<u8> {
let root = if self.root.name.is_empty() { El::new("worksheet") } else { self.root.clone() };
@@ -230,12 +235,13 @@ impl SheetPart {
cols.push(column_el(c));
}
let merges = self.merges.iter().map(|m| El::new("mergeCell").with_attr("ref", m.a1())).collect();
let owned = vec![
("dimension", Some(El::new("dimension").with_attr("ref", used(rows).map_or_else(|| "A1".to_string(), |r| r.a1())))),
("cols", (!self.columns.is_empty()).then_some(cols)),
("sheetData", Some(El::new("sheetData"))),
("mergeCells", list(&root, "mergeCells", merges)),
let mut owned = vec![
("dimension", vec![El::new("dimension").with_attr("ref", used(rows).map_or_else(|| "A1".to_string(), |r| r.a1()))]),
("cols", Vec::from_iter((!self.columns.is_empty()).then_some(cols))),
("sheetData", vec![El::new("sheetData")]),
("mergeCells", Vec::from_iter(list(&root, "mergeCells", merges))),
];
owned.extend(self.features.owned());
let mut root = assemble(&root, WORKSHEET, owned);
let prefixes: Vec<&str> = rows.iter().flat_map(|r| &r.attrs).filter_map(|(k, _)| k.split_once(':').map(|(p, _)| p)).collect();
crate::ns::declare_with(&mut root, &prefixes);
+11 -10
View File
@@ -21,18 +21,19 @@ pub fn num(n: f64) -> String {
/// `root` with the children named in `owned` regenerated. Each `(name, new)` takes the place
/// of the first child of that name, and later children of that name are dropped; when `root`
/// has none, `new` goes where `order` (the schema's sequence) puts it. `None` removes the
/// child. Every other child stays where it is, so elements the schema does not list
/// has none, `new` goes where `order` (the schema's sequence) puts it. No element (`None`)
/// removes the children; several (repeated blocks) go in a row. Every other child stays where it is, so elements the schema does not list
/// (`mc:AlternateContent`) keep their place.
pub(crate) fn assemble(root: &El, order: &[&str], mut owned: Vec<(&str, Option<El>)>) -> El {
pub(crate) fn assemble<I: IntoIterator<Item = El>>(root: &El, order: &[&str], owned: Vec<(&str, I)>) -> El {
let mut owned: Vec<(&str, Vec<El>)> = owned.into_iter().map(|(n, e)| (n, e.into_iter().collect())).collect();
let pos = |n: &str| order.iter().position(|o| *o == n).unwrap_or(order.len());
owned.sort_by_key(|(n, _)| pos(n));
// The children of `root` each owned name replaces, so a regenerated one is not also written
// where `order` would put it.
let present: Vec<&str> = owned.iter().map(|(n, _)| *n).filter(|n| root.child(*n).is_some()).collect();
let mut out = El { name: root.name.clone(), attrs: root.attrs.clone(), children: Vec::new() };
let mut pending: Vec<(&str, Option<El>)> = Vec::new();
let mut later: Vec<(&str, Option<El>)> = Vec::new();
let mut pending: Vec<(&str, Vec<El>)> = Vec::new();
let mut later: Vec<(&str, Vec<El>)> = Vec::new();
for (n, e) in owned {
if present.contains(&n) { later.push((n, e)) } else { pending.push((n, e)) }
}
@@ -47,12 +48,12 @@ pub(crate) fn assemble(root: &El, order: &[&str], mut owned: Vec<(&str, Option<E
// Missing children that come before this one.
while pending.first().is_some_and(|(n, _)| pos(n) < p) {
let (_, e) = pending.remove(0);
out.children.extend(e.map(Node::El));
out.children.extend(e.into_iter().map(Node::El));
}
}
if let Some(i) = later.iter().position(|(n, _)| *n == c.name) {
let (n, e) = later.remove(i);
out.children.extend(e.map(Node::El));
out.children.extend(e.into_iter().map(Node::El));
done.push(n);
continue;
}
@@ -61,7 +62,7 @@ pub(crate) fn assemble(root: &El, order: &[&str], mut owned: Vec<(&str, Option<E
}
out.children.push(node.clone());
}
out.children.extend(pending.into_iter().filter_map(|(_, e)| e).map(Node::El));
out.children.extend(pending.into_iter().flat_map(|(_, e)| e).map(Node::El));
out
}
@@ -148,9 +149,9 @@ mod tests {
let out = assemble(
&root,
&order,
vec![("c", Some(El::new("c").with_attr("new", 1))), ("b", Some(El::new("b"))), ("d", Some(El::new("d"))), ("a", None)],
vec![("c", vec![El::new("c").with_attr("new", 1), El::new("c")]), ("b", vec![El::new("b")]), ("d", vec![El::new("d")]), ("a", vec![])],
);
assert_eq!(out.to_xml(), r#"<w><mc:AlternateContent/><b/><c new="1"/><d/><ext/></w>"#);
assert_eq!(out.to_xml(), r#"<w><mc:AlternateContent/><b/><c new="1"/><c/><d/><ext/></w>"#);
let empty = assemble(&El::new("w"), &order, vec![("d", Some(El::new("d"))), ("a", Some(El::new("a")))]);
assert_eq!(empty.to_xml(), "<w><a/><d/></w>");
}
+106 -3
View File
@@ -30,11 +30,11 @@ fn excel_like() -> Vec<u8> {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet {ns} xr:uid="{{00000000-0001-0000-0000-000000000000}}"><sheetPr><tabColor rgb="FFFF0000"/></sheetPr><dimension ref="A1:C3"/><sheetViews><sheetView tabSelected="1" workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="15" x14ac:dyDescent="0.25"/><cols><col min="1" max="2" width="12.5" customWidth="1"/><col min="3" max="3" width="9" hidden="1" style="1"/></cols>
<sheetData><row r="1" spans="1:3" x14ac:dyDescent="0.25"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c><c r="C1" s="1"><v>0.1</v></c></row><row r="2" ht="30" customHeight="1"><c r="A2"><f>SUM(C1:C3)</f><v>0.30000000000000004</v></c><c r="B2" t="b"><v>1</v></c><c r="C2" s="1"><f t="shared" ref="C2:C3" si="0">C1*2</f><v>0.2</v></c></row><row r="3"><c r="A3" t="e"><v>#N/A</v></c><c r="B3" t="inlineStr"><is><t xml:space="preserve"> inline </t></is></c><c r="C3" s="1"><f t="shared" si="0"/><v>0.4</v></c></row></sheetData>
<mergeCells count="1"><mergeCell ref="A5:B6"/></mergeCells><conditionalFormatting sqref="C1:C3"><cfRule type="cellIs" dxfId="0" priority="1" operator="greaterThan"><formula>0.25</formula></cfRule></conditionalFormatting><pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/><drawing r:id="rId1"/>
<mergeCells count="1"><mergeCell ref="A5:B6"/></mergeCells><conditionalFormatting sqref="C1:C3"><cfRule type="cellIs" dxfId="0" priority="1" operator="greaterThan"><formula>0.25</formula></cfRule></conditionalFormatting><dataValidations count="1"><dataValidation type="list" allowBlank="1" showInputMessage="1" sqref="A1:A3 C:C" xr:uid="{{00000000-0002-0000-0000-000000000000}}"><formula1>"a,b"</formula1></dataValidation></dataValidations><hyperlinks><hyperlink ref="A1" r:id="rId2" tooltip="t"/><hyperlink ref="B1" location="Data!A1" display="here"/></hyperlinks><printOptions gridLines="1"/><pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/><pageSetup orientation="landscape" fitToWidth="1"/><headerFooter differentFirst="1"><oddHeader>&amp;C&amp;P</oddHeader><firstFooter xml:space="preserve"> x </firstFooter></headerFooter><rowBreaks count="1" manualBreakCount="1"><brk id="10" max="16383" man="1"/></rowBreaks><drawing r:id="rId1"/><legacyDrawing r:id="rId3"/>
<extLst><ext uri="{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}" xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"><x14:sparklineGroups xmlns:xm="http://schemas.microsoft.com/office/excel/2006/main"><x14:sparklineGroup><x14:sparklines><x14:sparkline><xm:f>Data!C1:C3</xm:f><xm:sqref>D1</xm:sqref></x14:sparkline></x14:sparklines></x14:sparklineGroup></x14:sparklineGroups></ext></extLst></worksheet>"#
);
let sheet1_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}/drawing" Target="../drawings/drawing1.xml"/><Relationship Id="rId2" Type="{RT}/hyperlink" Target="https://example.com/a?b&amp;c" TargetMode="External"/><Relationship Id="rId3" Type="{RT}/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rId4" Type="{RT}/comments" Target="../comments1.xml"/></Relationships>"#
);
let styles = format!(
r#"<styleSheet {ns}><numFmts count="1"><numFmt numFmtId="164" formatCode="0.0%"/></numFmts><fonts count="2" x14ac:knownFonts="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font><font><b/><i val="0"/><u val="double"/><vertAlign val="superscript"/><sz val="9.5"/><color rgb="FF0000FF" tint="-0.25"/><name val="Arial"/></font></fonts>
@@ -50,7 +50,7 @@ fn excel_like() -> Vec<u8> {
let package_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.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="xml" ContentType="application/xml"/><Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.ms-excel.sheet.macroEnabled.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/chartsheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>"#;
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="xml" ContentType="application/xml"/><Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.ms-excel.sheet.macroEnabled.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/chartsheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/><Override PartName="/xl/comments1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>"#;
zip(&[
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", package_rels.as_bytes()),
@@ -66,10 +66,15 @@ fn excel_like() -> Vec<u8> {
("xl/styles.xml", styles.as_bytes()),
("xl/sharedStrings.xml", strings.as_bytes()),
("xl/calcChain.xml", b"<calcChain/>"),
("xl/comments1.xml", COMMENTS.as_bytes()),
("xl/drawings/vmlDrawing1.vml", VML.as_bytes()),
("xl/vbaProject.bin", &[0xD0, 0xCF, 0x11, 0xE0, 1, 2, 3]),
])
}
const COMMENTS: &str = "<comments xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><authors><author>Ann</author></authors><commentList><comment ref=\"B2\" authorId=\"0\" shapeId=\"0\"><text><r><rPr><b/></rPr><t>Ann:</t></r><r><t xml:space=\"preserve\">\nnote</t></r></text></comment><comment ref=\"bad\"/></commentList></comments>";
const VML: &str = "<xml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><o:shapelayout v:ext=\"edit\"><o:idmap v:ext=\"edit\" data=\"1\"/></o:shapelayout><v:shape id=\"_x0000_s1025\" style=\"position:absolute;width:120pt;height:60pt\"><x:ClientData ObjectType=\"Note\"><x:Anchor>2, 15, 0, 2, 4, 15, 4, 16</x:Anchor><x:Row>1</x:Row><x:Column>1</x:Column><x:Visible/></x:ClientData></v:shape></xml>";
fn read(bytes: &[u8]) -> Document {
Document::read(bytes, &Limits::default()).unwrap()
}
@@ -114,6 +119,12 @@ fn assert_same(a: &Document, b: &Document) {
assert_eq!((&x.part.part, &x.part.columns, &x.part.merges), (&y.part.part, &y.part.columns, &y.part.merges));
assert_eq!(String::from_utf8(x.part.to_xml(&x.rows)), String::from_utf8(y.part.to_xml(&y.rows)));
assert_eq!(x.part.rels.list, y.part.rels.list);
assert_eq!(x.part.features, y.part.features);
assert_eq!(x.notes, y.notes);
assert_eq!(
x.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml())),
y.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml()))
);
}
let mut pa = a.parts.clone();
let mut pb = b.parts.clone();
@@ -169,6 +180,36 @@ fn a_workbook_round_trips() {
assert!(types.contains("macroEnabled.main+xml") && types.contains(r#"Extension="bin""#) && !types.contains("calcChain"), "{types}");
}
#[test]
fn features_are_read() {
let doc = read(&excel_like());
let ws = doc.worksheets[0].as_ref().unwrap();
let f = &ws.part.features;
assert_eq!(f.views[0].pane.as_ref().and_then(|p| p.state.as_deref()), Some("frozen"));
assert_eq!(f.views[0].tab_selected, Some(true));
let dv = &f.validations.as_ref().unwrap().items[0];
assert_eq!((dv.kind.as_deref(), dv.formula1.as_deref(), dv.sqref.len()), (Some("list"), Some("\"a,b\""), 2));
assert_eq!(dv.attrs, [("xr:uid".to_string(), "{00000000-0002-0000-0000-000000000000}".to_string())]);
assert_eq!(craft_xlsx::range_ref(&dv.sqref[1]), "C:C");
assert_eq!(f.conditional_formats[0].rules[0].formulas, ["0.25"]);
assert_eq!(f.hyperlinks[0].url.as_deref(), Some("https://example.com/a?b&c"));
assert_eq!((f.hyperlinks[1].location.as_deref(), f.hyperlinks[1].url.as_deref()), (Some("Data!A1"), None));
assert_eq!(f.print.options.as_ref().and_then(|o| o.grid_lines), Some(true));
assert_eq!(f.print.setup.as_ref().and_then(|s| s.orientation.as_deref()), Some("landscape"));
let hf = f.print.header_footer.as_ref().unwrap();
assert_eq!((hf.odd_header.as_deref(), hf.first_footer.as_deref(), hf.different_first), (Some("&C&P"), Some(" x "), Some(true)));
assert_eq!((f.print.row_breaks[0].id, f.print.row_breaks[0].man), (Some(10), Some(true)));
assert_eq!(f.legacy_drawing.as_deref(), Some("rId3"));
let comments = ws.comments.as_ref().unwrap();
assert_eq!(comments.comments.len(), 1);
assert_eq!((comments.author(&comments.comments[0]), &*comments.comments[0].text.text), (Some("Ann"), "Ann:\nnote"));
assert_eq!(
ws.notes,
[craft_xlsx::NoteShape { at: CellRef::new(1, 1), visible: true, anchor: Some([2, 15, 0, 2, 4, 15, 4, 16]), size_pt: Some((120.0, 60.0)) }]
);
assert!(doc.parts.iter().any(|p| p.name == "xl/drawings/vmlDrawing1.vml") && !doc.parts.iter().any(|p| p.name == "xl/comments1.xml"));
}
#[test]
fn edits_are_written() {
let mut doc = read(&excel_like());
@@ -196,6 +237,68 @@ fn edits_are_written() {
assert_eq!(rels.all("worksheet").count(), 1);
}
#[test]
fn features_are_edited() {
let mut doc = read(&excel_like());
let ws = doc.worksheets[0].as_mut().unwrap();
let f = &mut ws.part.features;
f.hyperlinks[0].url = Some("https://example.org/".into());
f.conditional_formats.push(craft_xlsx::ConditionalFormatting {
sqref: vec![Range::parse("A1:A9").unwrap()],
rules: vec![craft_xlsx::CfRule { kind: Some("expression".into()), priority: Some(2), formulas: vec!["A1>1".into()], ..Default::default() }],
..Default::default()
});
f.print.setup = None;
// A comment on a sheet without a VML drawing gets one, in a shape block no other drawing uses.
let empty = doc.worksheets[2].as_mut().unwrap();
empty.comments = Some(craft_xlsx::Comments {
authors: vec!["Bo".into()],
comments: vec![craft_xlsx::Comment { at: CellRef::new(0, 0), author_id: Some(0), text: StringItem::plain("hi"), ..Default::default() }],
..Default::default()
});
let bytes = doc.write().unwrap();
let again = read(&bytes);
assert_same(&doc_after(&doc), &again);
let f = &again.worksheets[0].as_ref().unwrap().part.features;
assert_eq!(f.hyperlinks[0].url.as_deref(), Some("https://example.org/"));
assert_eq!(f.conditional_formats.len(), 2);
assert!(f.print.setup.is_none());
let sheet2 = again.worksheets[2].as_ref().unwrap();
assert_eq!(sheet2.comments.as_ref().map(|c| c.comments[0].text.text.to_string()), Some("hi".into()));
assert!(sheet2.part.features.legacy_drawing.is_some());
assert_eq!(sheet2.notes.iter().map(|n| (n.at, n.visible)).collect::<Vec<_>>(), [(CellRef::new(0, 0), false)]);
let vml = again.parts.iter().find(|p| p.name == "xl/drawings/vmlDrawing2.vml").unwrap();
assert!(String::from_utf8_lossy(&vml.bytes).contains(r#"data="2""#));
let sheet =
String::from_utf8(Package::open(&bytes, &Limits::default()).unwrap().opc().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
let order = [
"<conditionalFormatting",
"<conditionalFormatting sqref=\"A1:A9\"",
"<dataValidations",
"<hyperlinks",
"<printOptions",
"<pageMargins",
"<headerFooter",
];
let at: Vec<usize> = order.iter().map(|o| sheet.find(o).unwrap()).collect();
assert!(at.windows(2).all(|w| w[0] < w[1]), "{sheet}");
// Comments removed take their part and relationship with them.
let mut doc = again;
doc.worksheets[0].as_mut().unwrap().comments = None;
let bytes = doc.write().unwrap();
let mut p = Package::open(&bytes, &Limits::default()).unwrap();
assert!(p.opc().read("xl/comments1.xml").unwrap().is_none());
let wb = p.workbook().unwrap();
let part = p.sheet(&wb.sheets[0], |_| Ok(())).unwrap();
assert!(part.rels.first("comments").is_none());
}
/// The document as written: a link to a new URL has a relationship of its own, a new comments
/// part a VML drawing.
fn doc_after(doc: &Document) -> Document {
read(&doc.write().unwrap())
}
#[test]
fn a_new_workbook() {
let cell = |at: &str, value| Cell { at: CellRef::parse(at).unwrap(), value, ..Cell::default() };