mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 09:37:11 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # crates/docx/Cargo.toml
This commit is contained in:
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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></m:t><w:noBreakHyphen/></m:r><w:r><w:rPr><w:rFonts w:ascii="Symbol"/></w:rPr><w:t></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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ` `.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ` `: 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', " ")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `a` 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);
|
||||
}
|
||||
|
||||
@@ -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 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&bAB<>"'"), "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("é&日&é;"), "é&日&é;");
|
||||
// leading zeros are valid in numeric references, however many there are
|
||||
assert_eq!(unescape_attr("AB"), "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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
mod fcp7;
|
||||
mod fcpxml;
|
||||
mod otio;
|
||||
mod support;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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) },
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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}")))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>&C&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&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() };
|
||||
|
||||
Reference in New Issue
Block a user