mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-12 17:02:34 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6888c3800a | ||
|
|
26d0ecba74 | ||
|
|
9b1fe3ff23 | ||
|
|
653f596958 | ||
|
|
129efb16bd |
@@ -0,0 +1,31 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ci.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml': []
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/release.yml': []
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
@@ -27,11 +27,11 @@ jobs:
|
||||
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -28,10 +28,10 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
|
||||
+105
-9
@@ -15,6 +15,14 @@ use crate::xml::{A, El, W};
|
||||
|
||||
const DEG: f64 = 60_000.0;
|
||||
|
||||
/// Distinct path coordinate spaces whose guides are evaluated for one custom geometry; paths in
|
||||
/// further spaces are left out.
|
||||
const MAX_SPACES: usize = 16;
|
||||
|
||||
/// Guide-name bytes all cached spaces may hold between them; the first space counts towards it but is always kept.
|
||||
/// Every space copies the names, which the XML limits bound only once.
|
||||
const MAX_SPACE_NAME_BYTES: usize = 4 << 20;
|
||||
|
||||
/// Guide variables for one shape.
|
||||
pub struct Guides {
|
||||
vars: HashMap<String, f64>,
|
||||
@@ -66,6 +74,10 @@ impl Guides {
|
||||
set("7cd8", 18_900_000.0);
|
||||
Guides { vars: v }
|
||||
}
|
||||
/// Bytes of the guide names held.
|
||||
fn name_bytes(&self) -> usize {
|
||||
self.vars.keys().map(String::len).sum()
|
||||
}
|
||||
pub fn get(&self, name: &str) -> f64 {
|
||||
let name = name.trim();
|
||||
if let Some(v) = self.vars.get(name) {
|
||||
@@ -149,16 +161,51 @@ fn pt(g: &Guides, e: &El) -> (f64, f64) {
|
||||
/// Convert `a:custGeom` to custom paths. `w`/`h` is the shape size in EMU (used for guides and for
|
||||
/// paths that don't give their own coordinate space).
|
||||
pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
read_cust_geom_counting(cg, w_emu, h_emu).0
|
||||
}
|
||||
|
||||
/// [`read_cust_geom`], and how many paths were left out for lack of room in the guide caches
|
||||
/// (`(too many coordinate spaces, too many guide-name bytes)`); each cause is also logged once.
|
||||
fn read_cust_geom_counting(cg: &El, w_emu: f64, h_emu: f64) -> (Vec<CustomPath>, (usize, usize)) {
|
||||
let mut out = vec![];
|
||||
let Some(pl) = cg.child("pathLst") else { return out };
|
||||
let mut dropped = (0usize, 0usize);
|
||||
let Some(pl) = cg.child("pathLst") else { return (out, dropped) };
|
||||
let mut spaces: HashMap<(u64, u64), Guides> = HashMap::new();
|
||||
let mut name_bytes = 0usize;
|
||||
let mut budget_full = false;
|
||||
// The cap on cached spaces saves guide evaluation: a geometry without guides has none to save.
|
||||
let guided = ["avLst", "gdLst"].iter().any(|n| cg.child(n).is_some_and(|l| l.elements().next().is_some()));
|
||||
for p in pl.children_named("path").take(1000) {
|
||||
let pw = p.f64("w").unwrap_or(0.0);
|
||||
let ph = p.f64("h").unwrap_or(0.0);
|
||||
// Guides are evaluated in the path's own space when it has one.
|
||||
let (gw, gh) = if pw > 0.0 || ph > 0.0 { (pw, ph) } else { (w_emu, h_emu) };
|
||||
let mut g = Guides::new(gw, gh);
|
||||
g.eval_list(cg.child("avLst"));
|
||||
g.eval_list(cg.child("gdLst"));
|
||||
// The guide lists are evaluated once per coordinate space, not once per path.
|
||||
let key = (gw.to_bits(), gh.to_bits());
|
||||
if !spaces.contains_key(&key) {
|
||||
if guided && (budget_full || spaces.len() >= MAX_SPACES) {
|
||||
if budget_full {
|
||||
dropped.1 += 1;
|
||||
} else {
|
||||
dropped.0 += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut g = Guides::new(gw, gh);
|
||||
g.eval_list(cg.child("avLst"));
|
||||
g.eval_list(cg.child("gdLst"));
|
||||
// The first space is always kept; later ones only while the cache stays within budget.
|
||||
let bytes = g.name_bytes();
|
||||
if guided && !spaces.is_empty() && name_bytes.saturating_add(bytes) > MAX_SPACE_NAME_BYTES {
|
||||
// Every space holds the same names, so no later space fits either.
|
||||
budget_full = true;
|
||||
dropped.1 += 1;
|
||||
continue;
|
||||
}
|
||||
name_bytes = name_bytes.saturating_add(bytes);
|
||||
spaces.insert(key, g);
|
||||
}
|
||||
let Some(g) = spaces.get(&key) else { continue };
|
||||
let mut d = String::new();
|
||||
let mut cur = (0.0, 0.0);
|
||||
let mut start = (0.0, 0.0);
|
||||
@@ -166,7 +213,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
match c.local() {
|
||||
"moveTo" => {
|
||||
if let Some(q) = c.child("pt") {
|
||||
cur = pt(&g, q);
|
||||
cur = pt(g, q);
|
||||
start = cur;
|
||||
d.push_str("M ");
|
||||
num(&mut d, cur.0);
|
||||
@@ -177,7 +224,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
"lnTo" => {
|
||||
if let Some(q) = c.child("pt") {
|
||||
cur = pt(&g, q);
|
||||
cur = pt(g, q);
|
||||
d.push_str("L ");
|
||||
num(&mut d, cur.0);
|
||||
d.push(' ');
|
||||
@@ -186,7 +233,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
}
|
||||
"cubicBezTo" => {
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(&g, q)).collect();
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(g, q)).collect();
|
||||
if let [a, b, e] = pts.as_slice() {
|
||||
d.push_str("C ");
|
||||
for (x, y) in [a, b, e] {
|
||||
@@ -199,7 +246,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
}
|
||||
"quadBezTo" => {
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(&g, q)).collect();
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(g, q)).collect();
|
||||
if let [a, e] = pts.as_slice() {
|
||||
d.push_str("Q ");
|
||||
for (x, y) in [a, e] {
|
||||
@@ -236,7 +283,16 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
let (ow, oh) = if pw > 0.0 || ph > 0.0 { (pw, ph) } else { (w_emu.max(1.0), h_emu.max(1.0)) };
|
||||
out.push(CustomPath { w: ow, h: oh, d: d.trim_end().to_string(), fill, stroke: p.bool("stroke").unwrap_or(true) });
|
||||
}
|
||||
out
|
||||
if dropped.0 > 0 {
|
||||
log::warn!("pptx: a custom geometry uses more than {MAX_SPACES} distinct path coordinate spaces; {} later path(s) are not kept", dropped.0);
|
||||
}
|
||||
if dropped.1 > 0 {
|
||||
log::warn!(
|
||||
"pptx: a custom geometry's guide names pass {MAX_SPACE_NAME_BYTES} bytes across its coordinate spaces; {} later path(s) are not kept",
|
||||
dropped.1
|
||||
);
|
||||
}
|
||||
(out, dropped)
|
||||
}
|
||||
|
||||
/// Ellipse parametric angle for a visual angle `a` (radians).
|
||||
@@ -470,6 +526,46 @@ mod tests {
|
||||
assert_eq!(paths[1].d, "M 1 1 Q 2 2 3 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_share_guides_and_distinct_spaces_are_capped() {
|
||||
let path = |w: usize| format!(r#"<a:path w="{w}" h="10"><a:moveTo><a:pt x="mid" y="1"/></a:moveTo></a:path>"#);
|
||||
let paths: String = (1..=40).map(|i| path(10 * i)).collect();
|
||||
let x = format!(
|
||||
r#"<a:custGeom xmlns:a="x"><a:gdLst><a:gd name="mid" fmla="*/ w 1 2"/></a:gdLst><a:pathLst>{paths}{}</a:pathLst></a:custGeom>"#,
|
||||
path(10)
|
||||
);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
let (out, dropped) = read_cust_geom_counting(&d.root, 1.0, 1.0);
|
||||
assert_eq!(out.len(), MAX_SPACES + 1, "16 spaces plus the repeat of the first");
|
||||
assert_eq!(dropped, (40 - MAX_SPACES, 0), "the paths of later spaces are counted, to be warned about");
|
||||
assert!(out[0].d.starts_with("M 5 1"), "{}", out[0].d);
|
||||
assert!(out[MAX_SPACES].d.starts_with("M 5 1"), "{}", out[MAX_SPACES].d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_name_bytes_across_spaces_are_budgeted() {
|
||||
let name = "n".repeat(3 << 20);
|
||||
let path = |w: usize| format!(r#"<a:path w="{w}" h="10"><a:moveTo><a:pt x="{name}" y="1"/></a:moveTo></a:path>"#);
|
||||
let x = format!(
|
||||
r#"<a:custGeom xmlns:a="x"><a:gdLst><a:gd name="{name}" fmla="*/ w 1 2"/></a:gdLst><a:pathLst>{}{}{}</a:pathLst></a:custGeom>"#,
|
||||
path(10),
|
||||
path(20),
|
||||
path(10)
|
||||
);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
let (out, dropped) = read_cust_geom_counting(&d.root, 1.0, 1.0);
|
||||
assert_eq!(out.len(), 2, "the second 3 MiB space is over budget and left out; the first space's repeat stays");
|
||||
assert_eq!(dropped, (0, 1), "the path left out is counted, to be warned about");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_free_geometries_are_not_capped_by_space() {
|
||||
let paths: String = (1..=40).map(|i| format!(r#"<a:path w="{}" h="10"><a:moveTo><a:pt x="1" y="1"/></a:moveTo></a:path>"#, 10 * i)).collect();
|
||||
let x = format!(r#"<a:custGeom xmlns:a="x"><a:avLst/><a:gdLst/><a:pathLst>{paths}</a:pathLst></a:custGeom>"#);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
assert_eq!(read_cust_geom(&d.root, 1.0, 1.0).len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_path() {
|
||||
let p = CustomPath { w: 100.0, h: 50.0, d: "M 0 0 L 100 0 C 100 10 90 50 50 50 Q 0 50 0 0 Z".into(), fill: FillMode::Norm, stroke: true };
|
||||
|
||||
@@ -951,6 +951,7 @@ fn cx_text(w: &mut W, t: &str) {
|
||||
|
||||
/// A `cx:lvl` of points.
|
||||
fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) {
|
||||
let pts = capped(pts);
|
||||
let mut a = A::new().a("ptCount", pts.len());
|
||||
if let Some(f) = format {
|
||||
a = a.a("formatCode", f);
|
||||
@@ -976,14 +977,39 @@ fn kept_region_layout(raw: &str) -> Option<String> {
|
||||
lp.name.starts_with("cx:").then(|| lp.to_xml())
|
||||
}
|
||||
|
||||
/// Points one `chartEx` part may carry in all, categories repeated for every series: Excel's 255
|
||||
/// series x 32,000 points. Series past it are left out with a warning (the first is always written).
|
||||
const MAX_CHARTEX_POINTS: usize = 255 * 32_000;
|
||||
|
||||
/// How many of `c`'s series fit [`MAX_CHARTEX_POINTS`] (at least one).
|
||||
fn chartex_series_within_budget(c: &Chart) -> usize {
|
||||
let level = |v: &Vec<String>| v.len().min(MAX_CACHE_POINTS);
|
||||
let cats = if c.categories.is_empty() {
|
||||
0
|
||||
} else {
|
||||
level(&c.categories).saturating_add(c.category_levels.iter().map(level).fold(0usize, usize::saturating_add))
|
||||
};
|
||||
let mut left = MAX_CHARTEX_POINTS;
|
||||
for (n, s) in c.series.iter().enumerate() {
|
||||
let cost = cats.saturating_add(s.values.len().min(MAX_CACHE_POINTS));
|
||||
if n > 0 && cost > left {
|
||||
log::warn!("pptx: chart part stopped after {n} series; its data passed {MAX_CHARTEX_POINTS} points");
|
||||
return n;
|
||||
}
|
||||
left = left.saturating_sub(cost);
|
||||
}
|
||||
c.series.len()
|
||||
}
|
||||
|
||||
/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data`
|
||||
/// per series (categories and values), then the plot area with each series' layout, the axes
|
||||
/// the layout uses, the title and the legend.
|
||||
pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
let n_series = chartex_series_within_budget(c);
|
||||
let mut w = W::new();
|
||||
w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX));
|
||||
w.open0("cx:chartData");
|
||||
for (i, s) in c.series.iter().enumerate() {
|
||||
for (i, s) in c.series.iter().enumerate().take(n_series) {
|
||||
w.open("cx:data", A::new().a("id", i));
|
||||
if !c.categories.is_empty() {
|
||||
w.open("cx:strDim", A::new().a("type", "cat"));
|
||||
@@ -1023,7 +1049,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
_ => "clusteredColumn",
|
||||
};
|
||||
let one = !matches!(c.kind, ChartType::BoxWhisker);
|
||||
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) {
|
||||
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { n_series }) {
|
||||
w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")));
|
||||
cx_text(&mut w, &s.name);
|
||||
if c.series_data_labels(s) {
|
||||
@@ -1282,6 +1308,20 @@ mod tests {
|
||||
mod cache_cap_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_chartex_part_stops_adding_series_past_excels_point_count() {
|
||||
const { assert!(MAX_CHARTEX_POINTS >= 255 * 32_000) };
|
||||
let mut c = Chart::sample(ChartType::Waterfall);
|
||||
c.categories = vec![String::new(); 32_000];
|
||||
let mut s = c.series[0].clone();
|
||||
s.values = Vec::new();
|
||||
c.series = vec![s; 300];
|
||||
// 32,000 categories per series: 255 fit exactly, the 256th does not.
|
||||
assert_eq!(chartex_series_within_budget(&c), 255);
|
||||
c.series.truncate(3);
|
||||
assert_eq!(chartex_series_within_budget(&c), 3);
|
||||
}
|
||||
|
||||
/// A cache longer than the cap is cut, never refused; anything a real chart holds (32,000
|
||||
/// points per series) is far under it. Zero-sized elements, so the test allocates nothing.
|
||||
#[test]
|
||||
|
||||
@@ -250,7 +250,24 @@ fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator<Item = f64> {
|
||||
(0..=n).map(move |k| lo + k as f64 * st)
|
||||
}
|
||||
|
||||
/// Most category-by-series cells one chart draws: Excel's 255 series x 32,000 points per series
|
||||
/// (8,160,000); ordinary charts hold a few hundred.
|
||||
const MAX_CHART_CELLS: usize = 255 * 32_000;
|
||||
|
||||
/// Whether the independent category and series lists make the drawing loops multiply past the budget.
|
||||
fn over_cell_budget(c: &Chart) -> bool {
|
||||
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||||
cells_over_budget(ncat, c.series.len())
|
||||
}
|
||||
|
||||
fn cells_over_budget(categories: usize, series: usize) -> bool {
|
||||
categories.saturating_mul(series.max(1)) > MAX_CHART_CELLS
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
|
||||
if over_cell_budget(c) {
|
||||
return;
|
||||
}
|
||||
match c.kind {
|
||||
// Histograms and Pareto charts are drawn as the column (and line) chart of their bins.
|
||||
ChartType::Histogram | ChartType::Pareto => {
|
||||
@@ -1437,6 +1454,16 @@ mod tests {
|
||||
|
||||
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
|
||||
#[test]
|
||||
fn category_by_series_cells_are_budgeted() {
|
||||
assert!(!over_cell_budget(&Chart::sample(ChartType::Column)));
|
||||
// Excel's maximum chart (255 series x 32,000 points) is within the budget; one more point is not.
|
||||
assert_eq!(MAX_CHART_CELLS, 8_160_000);
|
||||
assert!(!cells_over_budget(32_000, 255));
|
||||
assert!(cells_over_budget(32_001, 255));
|
||||
assert!(cells_over_budget(usize::MAX, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_data_gets_several_ticks() {
|
||||
let sc = deckcraft_model::chart::axis_scale(1e-13, 5e-13, None, None, None, 8, true);
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
sonar.projectKey=storytold_deckcraft
|
||||
sonar.organization=storytold
|
||||
|
||||
# githubactions:S7637 asks for every action to be pinned to a commit SHA. A workflow covered by
|
||||
# .github/workflows/actions.lock runs the commit the lockfile records, so its tag refs are pinned
|
||||
# and the lockfile covers the actions those actions use too. Sonar does not read lockfiles yet, so
|
||||
# the rule flags the tag refs anyway:
|
||||
# https://community.sonarsource.com/t/githubactions-s7637-false-positives-for-actions-pinned-through-native-actions-lock/190814
|
||||
sonar.issue.ignore.multicriteria=gha-lockfile
|
||||
sonar.issue.ignore.multicriteria.gha-lockfile.ruleKey=githubactions:S7637
|
||||
sonar.issue.ignore.multicriteria.gha-lockfile.resourceKey=.github/workflows/**
|
||||
|
||||
Reference in New Issue
Block a user