ooxml-core 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +21 -43
  2. package/dist/chart/axis-nice.d.ts +97 -0
  3. package/dist/chart/axis-nice.d.ts.map +1 -0
  4. package/dist/chart/histogram-binning.d.ts +40 -0
  5. package/dist/chart/histogram-binning.d.ts.map +1 -0
  6. package/dist/chart/index.cjs +167 -0
  7. package/dist/chart/index.d.ts +2 -0
  8. package/dist/chart/index.d.ts.map +1 -1
  9. package/dist/chart/index.mjs +162 -1
  10. package/dist/index.cjs +167 -0
  11. package/dist/index.mjs +167 -0
  12. package/dist/pptx/automation/index.cjs +1572 -863
  13. package/dist/pptx/automation/index.d.ts +4 -3
  14. package/dist/pptx/automation/index.d.ts.map +1 -1
  15. package/dist/pptx/automation/index.mjs +1572 -863
  16. package/dist/pptx/chunks/color-ref-BM66NGFP.d.ts +42 -0
  17. package/dist/pptx/chunks/color-ref-BM66NGFP.d.ts.map +1 -0
  18. package/dist/pptx/chunks/{index-D0yGoLyw.d.ts → index-CcQTCBnT.d.ts} +63 -479
  19. package/dist/pptx/chunks/index-CcQTCBnT.d.ts.map +1 -0
  20. package/dist/pptx/chunks/{index-BtBj3VG1.d.ts → index-DHoPgFcH.d.ts} +529 -11
  21. package/dist/pptx/chunks/index-DHoPgFcH.d.ts.map +1 -0
  22. package/dist/pptx/chunks/{text-operations-GuisjlbR.d.ts → text-operations-BzdehTeY.d.ts} +50 -51
  23. package/dist/pptx/chunks/text-operations-BzdehTeY.d.ts.map +1 -0
  24. package/dist/pptx/cli/index.cjs +1204 -489
  25. package/dist/pptx/cli/index.d.ts +1 -1
  26. package/dist/pptx/cli/index.mjs +1204 -489
  27. package/dist/pptx/converter/index.cjs +8 -5
  28. package/dist/pptx/converter/index.d.ts +1 -1
  29. package/dist/pptx/converter/index.mjs +8 -5
  30. package/dist/pptx/index.cjs +1699 -989
  31. package/dist/pptx/index.d.ts +5 -4
  32. package/dist/pptx/index.mjs +1698 -990
  33. package/dist/pptx/ui/index.cjs +510 -0
  34. package/dist/pptx/ui/index.d.ts +80 -0
  35. package/dist/pptx/ui/index.d.ts.map +1 -0
  36. package/dist/pptx/ui/index.mjs +499 -0
  37. package/package.json +15 -4
  38. package/dist/pptx/chunks/index-BtBj3VG1.d.ts.map +0 -1
  39. package/dist/pptx/chunks/index-D0yGoLyw.d.ts.map +0 -1
  40. package/dist/pptx/chunks/text-operations-GuisjlbR.d.ts.map +0 -1
package/README.md CHANGED
@@ -17,14 +17,13 @@ One package, one XML model, every format: Word, PowerPoint, Excel and the shared
17
17
  [**Packages and areas**](#features-and-api-one-package-many-areas)  · 
18
18
  [**Install**](#install)  · 
19
19
  [**Examples**](#quick-start)  · 
20
- [**Roadmap**](#roadmap)  · 
21
20
  [**Contributing**](CONTRIBUTING.md)
22
21
 
23
22
  </div>
24
23
 
25
24
  ## Why ooxml-core?
26
25
 
27
- - **No UI, no framework.** Everything runs in browsers, Node.js, Bun, workers and serverless functions. The viewer apps ([docx-viewer](https://github.com/ChristopherVR/docx-viewer), [pptx-viewer](https://github.com/ChristopherVR/pptx-viewer)) are thin interfaces on top of it, and so can your application be.
26
+ - **No UI, no framework.** Everything runs in browsers, Node.js, Bun, workers and serverless functions. The viewer apps ([docx](https://christophervr.github.io/ooxml/docx/), [xlsx](https://christophervr.github.io/ooxml/xlsx/), [visio](https://christophervr.github.io/ooxml/visio/), [teams](https://christophervr.github.io/ooxml/teams/) and [pptx-viewer](https://github.com/ChristopherVR/pptx-viewer)) are thin interfaces on top of it, and so can your application be.
28
27
  - **One package, one structure.** All formats share the same XML model, packaging layer, units, colours and geometry. Each format is an _area_ of this one package, not a separate dependency to keep in step.
29
28
  - **Round-trips without losing what it does not understand.** Documents are loaded into a model, edited, and written back. Untouched parts stay byte-for-byte, unknown markup is preserved, and edits that would damage unsupported content are rejected instead of silently dropped.
30
29
  - **Strict by default.** New code is strict TypeScript with branded measurement units, the shared `xml` area parses strictly (no DTD or entity expansion), and the `docx` area is checked against the ECMA-376 schemas in the test suite.
@@ -39,20 +38,24 @@ import { parseXml } from 'ooxml-core/xml'; // one area
39
38
  import { xml, opc } from 'ooxml-core'; // or by namespace (shared building blocks)
40
39
  ```
41
40
 
42
- | Area | What it is |
43
- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
- | `xml` | The shared XML model: strict DOM parsing and serialization, namespaces, namespace-aware helpers. |
45
- | `opc` | Open Packaging Conventions: relationships, content types, part paths, zip helpers, the hyperlink safety policy (`hyperlinkPolicy`) and document properties (core, app and custom, `opc/properties`) and digital signatures (`signature`: detection, the parts a writer strips, XML-DSig parsing and digest checks; the Node verifier is `/pptx/signature-node`). |
46
- | `units` | Branded EMU, twip and point types, constants and conversions. |
47
- | `color` | Hex, RGB, HSL and linear colour primitives, OOXML percent and angle parsing. |
48
- | `geometry` | DrawingML preset shapes, connection sites, clip paths, callouts and boolean shape operations. |
49
- | `diagram` | SmartArt (DiagramML), format-neutral: data model, layout/colour/quick-style parts, the cached `dsp:drawing` shape tree, relationship resolution and a loader. Used by `docx`; `pptx` re-imports its parsers. |
50
- | `digest` | Synchronous pure-TypeScript SHA-1, SHA-256, SHA-384 and SHA-512, ECMA-376 digest name normalisation (`SHA-512`, `SHA512`, `sha_512`, ...) and the agile password hash Office protection elements store (`hashPassword`, `verifyPasswordHash`). No Web Crypto, so it works on `http://`. Used by `xlsx` sheet and workbook protection. |
51
- | `crypto` | ECMA-376 package encryption ([MS-OFFCRYPTO]) for every format: `decryptOoxmlPackage`, `encryptOoxmlPackage` (agile AES-256/SHA-512 by default, or Standard), `isEncryptedOoxmlPackage`, and typed errors with a `code` (`password-required`, `incorrect-password`, `data-integrity`). The compound-file container comes from ole2 (inlined); not in the root entry. |
52
- | `collab` | Format-neutral real-time collaboration on Yjs: session and provider lifecycle, awareness/presence, transport-neutral sync (WebSocket, in-memory, any byte channel), update codecs, ordering helpers, asset sync and the product adapter seam. See `docs/collab-area.md`. |
53
- | `docx` | WordprocessingML: model, parser, preserving serializer, editing, validation. Also `/docx/embedded`. `/docx/layout` is the DOM-free pagination engine (an approximation of Word, not parity) and `/docx/load` detects and loads DOCX (also password-protected, `{ password }`) and legacy .doc (ole2 inlined). |
54
- | `pptx` | PresentationML: model, parser, serializer, editing, charts, SmartArt, converters, CLI, signatures. Subpaths `/pptx/converter`, `/pptx/cli`, `/pptx/signature-node`. |
55
- | `xlsx` | SpreadsheetML: workbook model, `.xlsx`/`.xlsm` reader and preserving writer, formula engine (dependency graph, dynamic arrays, 380+ functions), Excel number formats, editing commands with undo, and the DOM-free grid layout (sizes, colours, conditional formats, charts as SVG, drawing anchors). SmartArt is shown from the cached drawing (not re-laid out) and kept verbatim on save. `/xlsx/load` detects and loads `.xlsx`, `.xlsm` (also password-protected: `loadWorkbook(bytes, { password })`, `saveWorkbook(workbook, 'xlsx', { password })`), legacy `.xls` (ole2 inlined) and CSV, and refuses `.xlsb` with a typed error. Digital signatures are detected and reported, never kept on save. An approximation of Excel, not parity. |
41
+ | Area | What it is |
42
+ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `xml` | The shared XML model: strict DOM parsing and serialization, namespaces, namespace-aware helpers. |
44
+ | `opc` | Open Packaging Conventions: relationships, content types, part paths, zip helpers, the hyperlink safety policy (`hyperlinkPolicy`) and document properties (core, app and custom, `opc/properties`) and digital signatures (`signature`: detection, the parts a writer strips, XML-DSig parsing and digest checks; the Node verifier is `/pptx/signature-node`). |
45
+ | `units` | Branded EMU, twip and point types, constants and conversions. |
46
+ | `color` | Hex, RGB, HSL and linear colour primitives, OOXML percent and angle parsing. |
47
+ | `geometry` | DrawingML preset shapes, connection sites, clip paths, callouts and boolean shape operations. |
48
+ | `diagram` | SmartArt (DiagramML), format-neutral: data model, layout/colour/quick-style parts, the cached `dsp:drawing` shape tree, relationship resolution and a loader. Used by `docx`; `pptx` re-imports its parsers. |
49
+ | `digest` | Synchronous pure-TypeScript SHA-1, SHA-256, SHA-384 and SHA-512, ECMA-376 digest name normalisation (`SHA-512`, `SHA512`, `sha_512`, ...) and the agile password hash Office protection elements store (`hashPassword`, `verifyPasswordHash`). No Web Crypto, so it works on `http://`. Used by `xlsx` sheet and workbook protection. |
50
+ | `crypto` | ECMA-376 package encryption ([MS-OFFCRYPTO]) for every format: `decryptOoxmlPackage`, `encryptOoxmlPackage` (agile AES-256/SHA-512 by default, or Standard), `isEncryptedOoxmlPackage`, and typed errors with a `code` (`password-required`, `incorrect-password`, `data-integrity`). The compound-file container comes from ole2 (inlined); not in the root entry. |
51
+ | `collab` | Format-neutral real-time collaboration on Yjs: session and provider lifecycle, awareness/presence, transport-neutral sync (WebSocket, in-memory, any byte channel), update codecs, ordering helpers, asset sync and the product adapter seam. See `docs/collab-area.md`. |
52
+ | `docx` | WordprocessingML: model, parser, preserving serializer, editing, validation. Also `/docx/embedded`. `/docx/layout` is the DOM-free pagination engine (an approximation of Word, not parity) and `/docx/load` detects and loads DOCX (also password-protected, `{ password }`) and legacy .doc (ole2 inlined). |
53
+ | `pptx` | PresentationML: model, parser, serializer, editing, charts, SmartArt, converters, CLI, signatures. Subpaths `/pptx/converter`, `/pptx/cli`, `/pptx/signature-node`. |
54
+ | `xlsx` | SpreadsheetML: workbook model, `.xlsx`/`.xlsm` reader and preserving writer, formula engine (dependency graph, dynamic arrays, 380+ functions), Excel number formats, editing commands with undo, and the DOM-free grid layout (sizes, colours, conditional formats, charts as SVG, drawing anchors). SmartArt is shown from the cached drawing (not re-laid out) and kept verbatim on save. `/xlsx/load` detects and loads `.xlsx`, `.xlsm` (also password-protected: `loadWorkbook(bytes, { password })`, `saveWorkbook(workbook, 'xlsx', { password })`), legacy `.xls` (ole2 inlined) and CSV, and refuses `.xlsb` with a typed error. Digital signatures are detected and reported, never kept on save. An approximation of Excel, not parity. |
55
+ | `visio` | Visio: `.vsdx` parsing, loading and bounded editing, page layers and visibility, and a conservative preview of legacy `.vsd` (through ole2). See `docs/visio-legacy-vsd.md`. |
56
+ | `math` | Office Math: OMML to and from LaTeX and MathML, with colour and size helpers. |
57
+ | `teams` | The logic of the OpenTeams workspace: channels and chat on Yjs, presence, WebRTC calls with pluggable signalling, server configuration and the `createTeamsClient` store every UI binds to. Built on `collab`. See `docs/teams-area.md`. |
58
+ | `automation` | Headless document operations and filesystem execution shared by the MCP servers (`/automation`, `/automation/node`). |
56
59
 
57
60
  Legacy binary formats (`.doc`, `.xls`, `.ppt`) and the compound-file container live in the sibling package [`ole2`](https://github.com/ChristopherVR/ole2); this package never contains binary codecs, and `ole2` never contains modern OOXML.
58
61
 
@@ -105,34 +108,9 @@ const rels = parseRelationships(relsXml);
105
108
  const part = resolvePartPath('word/document.xml', rels.get('rId5')!.target);
106
109
  ```
107
110
 
108
- ## Roadmap
111
+ ## Contributing and roadmap
109
112
 
110
- The shared areas come first, then more formats on the same foundation:
111
-
112
- - **Shared layers:** DrawingML (fills, lines, effects, text, theme), charts, diagrams (SmartArt), maths, encryption primitives and schema-generated types, each as an area, written once for every format.
113
- - **More formats:** Visio (`.vsdx`, also an OPC package), and further Office Open XML parts as they are needed. Each arrives as its own area.
114
- - **One XML model:** the `pptx` area still uses its own XML object model and is compiled with relaxed TypeScript flags while it is migrated onto the shared `xml` area and tightened. New code is strict.
115
- - **Collaboration:** the Yjs and sync protocol that the viewers share will live here as a `collab` area.
116
-
117
- ## Development
118
-
119
- You need [Bun](https://bun.sh/) and Node.js 22 or newer.
120
-
121
- ```bash
122
- bun install
123
- bun run typecheck # strict project and the pptx project
124
- bun run test
125
- bun run build
126
- bun run test:package # packs the build and imports every entry point from a clean install
127
- ```
128
-
129
- The working agreements are in [AGENTS.md](AGENTS.md), and [PROVENANCE.md](PROVENANCE.md) records where each module came from.
130
-
131
- ## Documentation and related projects
132
-
133
- - [docx-viewer](https://github.com/ChristopherVR/docx-viewer) and [pptx-viewer](https://github.com/ChristopherVR/pptx-viewer): the editors and viewers built on this package.
134
- - [OOXML Office](https://christophervr.github.io/ooxml/): the suite's launcher page (`site/`), which opens the demos those viewers deploy to their own GitHub Pages sites.
135
- - [ole2](https://github.com/ChristopherVR/ole2): the compound-file container and legacy binary Office codecs.
113
+ `ooxml-core` is developed in the [ChristopherVR/ooxml](https://github.com/ChristopherVR/ooxml) repository, which also holds [`ooxml-ui`](https://www.npmjs.com/package/ooxml-ui), the MCP servers and the Word, Excel, Visio and OpenTeams viewers. Its README covers the repository layout, the development workflow, releases and the roadmap; the working agreements are in [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md), and [PROVENANCE.md](PROVENANCE.md) records where each module came from.
136
114
 
137
115
  ## License
138
116
 
@@ -0,0 +1,97 @@
1
+ /**
2
+ * PowerPoint's automatic value-axis scale.
3
+ *
4
+ * A chart that declares no `c:min` / `c:max` / `c:majorUnit` leaves the bounds
5
+ * to the application, and PowerPoint does NOT simply run the axis to the largest
6
+ * data point. It anchors the axis at zero, pads the data, then rounds out to a
7
+ * round number and labels it in round steps. The reporter's deck (issue #132)
8
+ * shows the difference plainly: a percentage chart whose bars top out at 52%
9
+ * gets `0% 20% 40% 60%` from PowerPoint and got `0% 10% 21% 31% 42% 52%` here,
10
+ * because the axis ran to the data maximum and was then divided into five.
11
+ *
12
+ * The rules, from Microsoft's documented automatic-scaling behaviour:
13
+ *
14
+ * - **Zero anchor.** With all-positive data, the axis starts at zero unless the
15
+ * smallest value is at least 5/6 of the largest, in which case the data
16
+ * occupies a narrow band high up and a zero-anchored axis would flatten it.
17
+ * All-negative data mirrors this, ending at zero.
18
+ * - **Headroom.** The far end is pushed out by 5% of the data span so the
19
+ * tallest bar does not touch the top of the plot.
20
+ * - **Round steps.** The major unit is the first "nice" number (1, 2, 2.5 or 5
21
+ * times a power of ten) at or above `span / targetIntervals`, and the bounds
22
+ * are then snapped outwards to whole multiples of it.
23
+ *
24
+ * The snap is what makes the interval count differ from `targetIntervals`:
25
+ * rounding 0.544 up to a multiple of 0.2 lands on 0.6, which is three steps, not
26
+ * four. That is the intent, and it is why PowerPoint's axes read in round
27
+ * numbers while an evenly-divided one does not.
28
+ */
29
+ /** A resolved automatic axis scale. */
30
+ export interface NiceAxisBounds {
31
+ min: number;
32
+ max: number;
33
+ /** Step between major gridlines; `max - min` is always a whole multiple. */
34
+ majorUnit: number;
35
+ }
36
+ /**
37
+ * How many gridline intervals PowerPoint's automatic value-axis scale aims
38
+ * for, given the chart's plot-area height in the "px" unit `computePlotLayout`
39
+ * returns (96 px/inch; see `PX_PER_POINT` above).
40
+ *
41
+ * This is NOT a universal constant (see this module's original doc comment,
42
+ * which admitted as much: "matches the axes PowerPoint draws for a chart of
43
+ * ORDINARY height"): a short chart gets fewer, coarser gridlines than a tall
44
+ * one, up to a point, because PowerPoint only has room to label so many
45
+ * without the axis text crowding.
46
+ *
47
+ * Measured via PowerPoint COM automation (`New-Object -ComObject
48
+ * PowerPoint.Application`, `Shapes.AddChart2`, reading back
49
+ * `chart.Axes(2).MinimumScale/.MaximumScale/.MajorUnit`): building a fresh
50
+ * clustered-column chart at increasing shape heights with the SAME 8-point,
51
+ * single-category data (values 1.2-3.2, the exact case reported against this
52
+ * engine, which this engine rendered 0-4 while PowerPoint drew 0-3.5):
53
+ *
54
+ * | chart height | plot height | PowerPoint axis | gridlines |
55
+ * |--------------|-------------|------------------|-----------|
56
+ * | 100pt | ~33pt | 0 to 5, unit 5 | 1 |
57
+ * | 150pt | ~83pt | 0 to 4, unit 1 | 4 |
58
+ * | 200pt | ~133pt | 0 to 3.5, unit 0.5 | 7 |
59
+ * | 300pt | ~233pt | 0 to 3.5, unit 0.5 | 7 |
60
+ * | 1000pt | ~933pt | 0 to 3.5, unit 0.5 | 7 (unchanged) |
61
+ *
62
+ * Cross-checked against two more datasets (0-52 and 0-9) at the same set of
63
+ * heights to separate "the target interval count" from "which nice-number
64
+ * bucket a particular span happens to round into": PowerPoint never asked for
65
+ * more than about 10 intervals even at a 1000pt-tall chart, it only asks for
66
+ * FEWER on a plot area too short to fit that many labels. That saturation
67
+ * (not a straight line) is why this is clamped at the top, not just scaled.
68
+ *
69
+ * Modelled as one gridline interval per ~20pt of plot height (roughly one
70
+ * axis-label line plus padding), clamped to `[1, 10]`. This will not be exact
71
+ * for every data range: the actual PowerPoint algorithm also nudges the count
72
+ * by how the span happens to snap to a nice number (see the module doc
73
+ * comment), which this simplified model does not attempt to reproduce. It is
74
+ * a large improvement over hard-coding the short-chart answer (4) for every
75
+ * chart regardless of size, which is the bug this was added to fix.
76
+ */
77
+ export declare function axisTargetIntervals(plotHeightPx: number): number;
78
+ /** The smallest nice step at or above `value`. */
79
+ export declare function niceAxisStep(value: number): number;
80
+ /**
81
+ * Resolve the automatic bounds and major unit for a value axis.
82
+ *
83
+ * @param dataMin Smallest plotted value.
84
+ * @param dataMax Largest plotted value.
85
+ * @param targetIntervals Roughly how many gridline steps to aim for. The snap to
86
+ * a round unit means the result is usually one fewer. Defaults to 4 only
87
+ * when the caller has no idea how tall the chart's plot area is; pass
88
+ * {@link axisTargetIntervals}`(plotHeightPx)` instead whenever that height is
89
+ * known; see that function's doc comment for why a fixed constant undershot
90
+ * real PowerPoint on any chart of ordinary-or-larger size.
91
+ * @param headroom Fraction of the data span added beyond the far end. 2D
92
+ * charts get PowerPoint's 5%; a right-angle-axes 3D bar chart gets none
93
+ * (its axis on data topping out at exactly 5 ends at 5, see
94
+ * `chart-3d-oblique-layout.ts`).
95
+ */
96
+ export declare function niceValueAxisBounds(dataMin: number, dataMax: number, targetIntervals?: number, headroom?: number): NiceAxisBounds;
97
+ //# sourceMappingURL=axis-nice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"axis-nice.d.ts","sourceRoot":"","sources":["../../src/chart/axis-nice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,uCAAuC;AACvC,MAAM,WAAW,cAAc;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;CAClB;AAwCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAOhE;AAED,kDAAkD;AAClD,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASlD;AAUD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CAClC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,eAAe,SAA2B,EAC1C,QAAQ,SAAW,GACjB,cAAc,CA6ChB"}
@@ -0,0 +1,40 @@
1
+ /** The `cx:layoutPr/cx:binning` properties that drive numeric binning. */
2
+ export interface HistogramOptions {
3
+ binSize?: number | undefined;
4
+ binCount?: number | undefined;
5
+ intervalClosed?: 'l' | 'r' | undefined;
6
+ underflow?: number | 'auto' | undefined;
7
+ overflow?: number | 'auto' | undefined;
8
+ }
9
+ /** Formats a bin edge for a label. */
10
+ export type HistogramValueFormatter = (value: number) => string;
11
+ export interface HistogramBin {
12
+ value: number;
13
+ label: string;
14
+ sourceIndices: number[];
15
+ }
16
+ /**
17
+ * Excel/PowerPoint's automatic histogram bin count when the author specifies
18
+ * neither `binSize` nor `binCount` (Scott's normal reference rule: bin width
19
+ * = 3.49 * sample standard deviation * n^(-1/3), then the count needed to
20
+ * span the data at that width). COM-verified against charts-com.pptx slide
21
+ * 30 / chartEx5.xml: 76 raw values spanning 1-24 (sample sd ~4.898) gave
22
+ * PowerPoint's own 6 bins of width 4 each; Scott's rule on the same data
23
+ * gives a raw width of ~4.035, which needs exactly 6 bins to span the range,
24
+ * matching PowerPoint bin-for-bin. The previous `ceil(sqrt(n))` fallback
25
+ * gave 9 bins on the same data, visibly narrower than PowerPoint's own.
26
+ */
27
+ export declare function scottBinCount(values: ReadonlyArray<number>, min: number, max: number): number;
28
+ /** Bin raw observations according to ChartEx binning properties. */
29
+ export declare function computeHistogramBins(values: ReadonlyArray<number>, options: HistogramOptions, format?: HistogramValueFormatter): HistogramBin[];
30
+ /**
31
+ * Aggregate raw observations by their (repeated) category label instead of by
32
+ * numeric value range: `cx:layoutPr/cx:aggregation` (COM-verified against
33
+ * charts-com.pptx slide 31 / chartEx6.xml) means the source rows are
34
+ * CATEGORICAL, one row per occurrence, with the value column carrying a
35
+ * constant `1` so summing it counts occurrences per category. Summing
36
+ * (rather than literally counting rows) also does the right thing for a
37
+ * dataset that authors a real numeric measure per row instead of a bare `1`.
38
+ */
39
+ export declare function aggregateByCategory(values: ReadonlyArray<number>, rawCategories: ReadonlyArray<string>): HistogramBin[];
40
+ //# sourceMappingURL=histogram-binning.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"histogram-binning.d.ts","sourceRoot":"","sources":["../../src/chart/histogram-binning.ts"],"names":[],"mappings":"AASA,0EAA0E;AAC1E,MAAM,WAAW,gBAAgB;IAChC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,cAAc,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,SAAS,CAAC;IACvC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CACvC;AAED,sCAAsC;AACtC,MAAM,MAAM,uBAAuB,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;AAEhE,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,EAAE,CAAC;CACxB;AAaD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAa7F;AAED,oEAAoE;AACpE,wBAAgB,oBAAoB,CACnC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,EAC7B,OAAO,EAAE,gBAAgB,EACzB,MAAM,GAAE,uBAAgC,GACtC,YAAY,EAAE,CA8EhB;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,EAC7B,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,GAClC,YAAY,EAAE,CAUhB"}
@@ -219,11 +219,178 @@ function computeStackedSeriesPlots(seriesValues, catCount, percent) {
219
219
  });
220
220
  }
221
221
 
222
+ // src/chart/axis-nice.ts
223
+ var NICE_STEPS = [1, 2, 2.5, 5, 10];
224
+ var HEADROOM = 0.05;
225
+ var POINTS_PER_GRIDLINE = 20;
226
+ var MIN_TARGET_INTERVALS = 1;
227
+ var MAX_TARGET_INTERVALS = 10;
228
+ var PX_PER_POINT = 4 / 3;
229
+ var DEFAULT_TARGET_INTERVALS = 4;
230
+ var ZERO_ANCHOR_RATIO = 5 / 6;
231
+ function axisTargetIntervals(plotHeightPx) {
232
+ if (!Number.isFinite(plotHeightPx) || plotHeightPx <= 0) {
233
+ return DEFAULT_TARGET_INTERVALS;
234
+ }
235
+ const plotHeightPt = plotHeightPx / PX_PER_POINT;
236
+ const raw = Math.round(plotHeightPt / POINTS_PER_GRIDLINE);
237
+ return Math.min(MAX_TARGET_INTERVALS, Math.max(MIN_TARGET_INTERVALS, raw));
238
+ }
239
+ function niceAxisStep(value) {
240
+ if (!Number.isFinite(value) || value <= 0) {
241
+ return 1;
242
+ }
243
+ const exponent = Math.floor(Math.log10(value));
244
+ const power = 10 ** exponent;
245
+ const mantissa = value / power;
246
+ const step = NICE_STEPS.find((candidate) => mantissa <= candidate + 1e-12) ?? 10;
247
+ return step * power;
248
+ }
249
+ function snap(value, unit, direction) {
250
+ const scaled = value / unit;
251
+ const rounded = direction === "up" ? Math.ceil(scaled - 1e-9) : Math.floor(scaled + 1e-9);
252
+ return rounded * unit;
253
+ }
254
+ function niceValueAxisBounds(dataMin, dataMax, targetIntervals = DEFAULT_TARGET_INTERVALS, headroom = HEADROOM) {
255
+ if (!Number.isFinite(dataMin) || !Number.isFinite(dataMax)) {
256
+ return { min: 0, max: 1, majorUnit: 0.5 };
257
+ }
258
+ const low = Math.min(dataMin, dataMax);
259
+ const high = Math.max(dataMin, dataMax);
260
+ const intervals = Math.max(1, Math.round(targetIntervals));
261
+ if (low === high) {
262
+ if (low === 0) {
263
+ return { min: 0, max: 1, majorUnit: 1 / intervals };
264
+ }
265
+ const unit2 = niceAxisStep(Math.abs(low) / intervals);
266
+ return low > 0 ? { min: 0, max: snap(low, unit2, "up") || unit2, majorUnit: unit2 } : { min: snap(low, unit2, "down"), max: 0, majorUnit: unit2 };
267
+ }
268
+ const dataSpan = high - low;
269
+ const padding = dataSpan * headroom;
270
+ let paddedMin;
271
+ let paddedMax;
272
+ if (low >= 0) {
273
+ paddedMin = low < high * ZERO_ANCHOR_RATIO ? 0 : low - padding;
274
+ paddedMax = high + padding;
275
+ } else if (high <= 0) {
276
+ paddedMax = high > low * ZERO_ANCHOR_RATIO ? 0 : high + padding;
277
+ paddedMin = low - padding;
278
+ } else {
279
+ paddedMin = low - padding;
280
+ paddedMax = high + padding;
281
+ }
282
+ const unit = niceAxisStep((paddedMax - paddedMin) / intervals);
283
+ const min = paddedMin === 0 ? 0 : snap(paddedMin, unit, "down");
284
+ let max = paddedMax === 0 ? 0 : snap(paddedMax, unit, "up");
285
+ if (max <= min) {
286
+ max = min + unit;
287
+ }
288
+ return { min, max, majorUnit: unit };
289
+ }
290
+
291
+ // src/chart/histogram-binning.ts
292
+ function binLabel(lower, upper, closed, format) {
293
+ return closed === "r" ? `(${format(lower)}, ${format(upper)}]` : `[${format(lower)}, ${format(upper)})`;
294
+ }
295
+ function scottBinCount(values, min, max) {
296
+ if (values.length < 2 || max <= min) {
297
+ return 1;
298
+ }
299
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
300
+ const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1);
301
+ const sd = Math.sqrt(variance);
302
+ if (!(sd > 0)) {
303
+ return 1;
304
+ }
305
+ const width = 3.49 * sd * values.length ** (-1 / 3);
306
+ return Math.max(1, Math.ceil((max - min) / width));
307
+ }
308
+ function computeHistogramBins(values, options, format = String) {
309
+ const closed = options.intervalClosed ?? "l";
310
+ const finite = values.map((value, sourceIndex) => ({ value, sourceIndex })).filter((item) => Number.isFinite(item.value));
311
+ if (finite.length === 0) {
312
+ return [];
313
+ }
314
+ const underflow = typeof options.underflow === "number" ? options.underflow : void 0;
315
+ const overflow = typeof options.overflow === "number" ? options.overflow : void 0;
316
+ const under = underflow === void 0 ? [] : finite.filter(
317
+ (item) => closed === "r" ? item.value <= underflow : item.value < underflow
318
+ );
319
+ const over = overflow === void 0 ? [] : finite.filter((item) => closed === "l" ? item.value >= overflow : item.value > overflow);
320
+ const regular = finite.filter((item) => !under.includes(item) && !over.includes(item));
321
+ const result = [];
322
+ if (underflow !== void 0) {
323
+ result.push({
324
+ value: under.length,
325
+ label: `${closed === "r" ? "\u2264" : "<"} ${format(underflow)}`,
326
+ sourceIndices: under.map((item) => item.sourceIndex)
327
+ });
328
+ }
329
+ if (regular.length > 0) {
330
+ const min = Math.min(...regular.map((item) => item.value));
331
+ const max = Math.max(...regular.map((item) => item.value));
332
+ const requestedSize = options.binSize && options.binSize > 0 ? options.binSize : void 0;
333
+ const requestedCount = options.binCount && options.binCount > 0 ? options.binCount : void 0;
334
+ const start = requestedSize ? Math.floor(min / requestedSize) * requestedSize : min;
335
+ const count = requestedSize ? Math.max(
336
+ closed === "l" ? Math.floor((max - start) / requestedSize) + 1 : Math.ceil((max - start) / requestedSize),
337
+ 1
338
+ ) : Math.max(
339
+ requestedCount ?? scottBinCount(
340
+ regular.map((item) => item.value),
341
+ min,
342
+ max
343
+ ),
344
+ 1
345
+ );
346
+ const width = requestedSize ?? Math.max((max - start) / count, 1);
347
+ const bins = Array.from({ length: count }, (_, index) => ({
348
+ value: 0,
349
+ label: binLabel(start + index * width, start + (index + 1) * width, closed, format),
350
+ sourceIndices: []
351
+ }));
352
+ for (const item of regular) {
353
+ const rawIndex = closed === "r" ? Math.ceil((item.value - start) / width) - 1 : Math.floor((item.value - start) / width);
354
+ const index = Math.max(0, Math.min(rawIndex, bins.length - 1));
355
+ const bin = bins[index];
356
+ if (!bin) continue;
357
+ bin.value += 1;
358
+ bin.sourceIndices.push(item.sourceIndex);
359
+ }
360
+ result.push(...bins);
361
+ }
362
+ if (overflow !== void 0) {
363
+ result.push({
364
+ value: over.length,
365
+ label: `${closed === "l" ? "\u2265" : ">"} ${format(overflow)}`,
366
+ sourceIndices: over.map((item) => item.sourceIndex)
367
+ });
368
+ }
369
+ return result;
370
+ }
371
+ function aggregateByCategory(values, rawCategories) {
372
+ const { uniqueCategories, rowIndexesByCategory } = groupRowsByCategory(rawCategories);
373
+ return uniqueCategories.map((label) => {
374
+ const rows = rowIndexesByCategory.get(label) ?? [];
375
+ return {
376
+ value: rows.reduce((sum, row) => sum + (values[row] ?? 0), 0),
377
+ label,
378
+ sourceIndices: rows
379
+ };
380
+ });
381
+ }
382
+
383
+ exports.aggregateByCategory = aggregateByCategory;
384
+ exports.axisTargetIntervals = axisTargetIntervals;
222
385
  exports.computeBoxStats = computeBoxStats;
386
+ exports.computeHistogramBins = computeHistogramBins;
223
387
  exports.computeLinearRegression = computeLinearRegression;
224
388
  exports.computeRSquared = computeRSquared;
225
389
  exports.computeStackedSeriesPlots = computeStackedSeriesPlots;
226
390
  exports.fitPolynomial = fitPolynomial;
227
391
  exports.groupRowsByCategory = groupRowsByCategory;
392
+ exports.niceAxisStep = niceAxisStep;
393
+ exports.niceValueAxisBounds = niceValueAxisBounds;
228
394
  exports.resolveBlankDisplay = resolveBlankDisplay;
395
+ exports.scottBinCount = scottBinCount;
229
396
  exports.visibleRuns = visibleRuns;
@@ -3,4 +3,6 @@ export * from './regression.js';
3
3
  export * from './box-stats.js';
4
4
  export * from './blank-display.js';
5
5
  export * from './stacked-series.js';
6
+ export * from './axis-nice.js';
7
+ export * from './histogram-binning.js';
6
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/chart/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/chart/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC"}
@@ -217,4 +217,165 @@ function computeStackedSeriesPlots(seriesValues, catCount, percent) {
217
217
  });
218
218
  }
219
219
 
220
- export { computeBoxStats, computeLinearRegression, computeRSquared, computeStackedSeriesPlots, fitPolynomial, groupRowsByCategory, resolveBlankDisplay, visibleRuns };
220
+ // src/chart/axis-nice.ts
221
+ var NICE_STEPS = [1, 2, 2.5, 5, 10];
222
+ var HEADROOM = 0.05;
223
+ var POINTS_PER_GRIDLINE = 20;
224
+ var MIN_TARGET_INTERVALS = 1;
225
+ var MAX_TARGET_INTERVALS = 10;
226
+ var PX_PER_POINT = 4 / 3;
227
+ var DEFAULT_TARGET_INTERVALS = 4;
228
+ var ZERO_ANCHOR_RATIO = 5 / 6;
229
+ function axisTargetIntervals(plotHeightPx) {
230
+ if (!Number.isFinite(plotHeightPx) || plotHeightPx <= 0) {
231
+ return DEFAULT_TARGET_INTERVALS;
232
+ }
233
+ const plotHeightPt = plotHeightPx / PX_PER_POINT;
234
+ const raw = Math.round(plotHeightPt / POINTS_PER_GRIDLINE);
235
+ return Math.min(MAX_TARGET_INTERVALS, Math.max(MIN_TARGET_INTERVALS, raw));
236
+ }
237
+ function niceAxisStep(value) {
238
+ if (!Number.isFinite(value) || value <= 0) {
239
+ return 1;
240
+ }
241
+ const exponent = Math.floor(Math.log10(value));
242
+ const power = 10 ** exponent;
243
+ const mantissa = value / power;
244
+ const step = NICE_STEPS.find((candidate) => mantissa <= candidate + 1e-12) ?? 10;
245
+ return step * power;
246
+ }
247
+ function snap(value, unit, direction) {
248
+ const scaled = value / unit;
249
+ const rounded = direction === "up" ? Math.ceil(scaled - 1e-9) : Math.floor(scaled + 1e-9);
250
+ return rounded * unit;
251
+ }
252
+ function niceValueAxisBounds(dataMin, dataMax, targetIntervals = DEFAULT_TARGET_INTERVALS, headroom = HEADROOM) {
253
+ if (!Number.isFinite(dataMin) || !Number.isFinite(dataMax)) {
254
+ return { min: 0, max: 1, majorUnit: 0.5 };
255
+ }
256
+ const low = Math.min(dataMin, dataMax);
257
+ const high = Math.max(dataMin, dataMax);
258
+ const intervals = Math.max(1, Math.round(targetIntervals));
259
+ if (low === high) {
260
+ if (low === 0) {
261
+ return { min: 0, max: 1, majorUnit: 1 / intervals };
262
+ }
263
+ const unit2 = niceAxisStep(Math.abs(low) / intervals);
264
+ return low > 0 ? { min: 0, max: snap(low, unit2, "up") || unit2, majorUnit: unit2 } : { min: snap(low, unit2, "down"), max: 0, majorUnit: unit2 };
265
+ }
266
+ const dataSpan = high - low;
267
+ const padding = dataSpan * headroom;
268
+ let paddedMin;
269
+ let paddedMax;
270
+ if (low >= 0) {
271
+ paddedMin = low < high * ZERO_ANCHOR_RATIO ? 0 : low - padding;
272
+ paddedMax = high + padding;
273
+ } else if (high <= 0) {
274
+ paddedMax = high > low * ZERO_ANCHOR_RATIO ? 0 : high + padding;
275
+ paddedMin = low - padding;
276
+ } else {
277
+ paddedMin = low - padding;
278
+ paddedMax = high + padding;
279
+ }
280
+ const unit = niceAxisStep((paddedMax - paddedMin) / intervals);
281
+ const min = paddedMin === 0 ? 0 : snap(paddedMin, unit, "down");
282
+ let max = paddedMax === 0 ? 0 : snap(paddedMax, unit, "up");
283
+ if (max <= min) {
284
+ max = min + unit;
285
+ }
286
+ return { min, max, majorUnit: unit };
287
+ }
288
+
289
+ // src/chart/histogram-binning.ts
290
+ function binLabel(lower, upper, closed, format) {
291
+ return closed === "r" ? `(${format(lower)}, ${format(upper)}]` : `[${format(lower)}, ${format(upper)})`;
292
+ }
293
+ function scottBinCount(values, min, max) {
294
+ if (values.length < 2 || max <= min) {
295
+ return 1;
296
+ }
297
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
298
+ const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1);
299
+ const sd = Math.sqrt(variance);
300
+ if (!(sd > 0)) {
301
+ return 1;
302
+ }
303
+ const width = 3.49 * sd * values.length ** (-1 / 3);
304
+ return Math.max(1, Math.ceil((max - min) / width));
305
+ }
306
+ function computeHistogramBins(values, options, format = String) {
307
+ const closed = options.intervalClosed ?? "l";
308
+ const finite = values.map((value, sourceIndex) => ({ value, sourceIndex })).filter((item) => Number.isFinite(item.value));
309
+ if (finite.length === 0) {
310
+ return [];
311
+ }
312
+ const underflow = typeof options.underflow === "number" ? options.underflow : void 0;
313
+ const overflow = typeof options.overflow === "number" ? options.overflow : void 0;
314
+ const under = underflow === void 0 ? [] : finite.filter(
315
+ (item) => closed === "r" ? item.value <= underflow : item.value < underflow
316
+ );
317
+ const over = overflow === void 0 ? [] : finite.filter((item) => closed === "l" ? item.value >= overflow : item.value > overflow);
318
+ const regular = finite.filter((item) => !under.includes(item) && !over.includes(item));
319
+ const result = [];
320
+ if (underflow !== void 0) {
321
+ result.push({
322
+ value: under.length,
323
+ label: `${closed === "r" ? "\u2264" : "<"} ${format(underflow)}`,
324
+ sourceIndices: under.map((item) => item.sourceIndex)
325
+ });
326
+ }
327
+ if (regular.length > 0) {
328
+ const min = Math.min(...regular.map((item) => item.value));
329
+ const max = Math.max(...regular.map((item) => item.value));
330
+ const requestedSize = options.binSize && options.binSize > 0 ? options.binSize : void 0;
331
+ const requestedCount = options.binCount && options.binCount > 0 ? options.binCount : void 0;
332
+ const start = requestedSize ? Math.floor(min / requestedSize) * requestedSize : min;
333
+ const count = requestedSize ? Math.max(
334
+ closed === "l" ? Math.floor((max - start) / requestedSize) + 1 : Math.ceil((max - start) / requestedSize),
335
+ 1
336
+ ) : Math.max(
337
+ requestedCount ?? scottBinCount(
338
+ regular.map((item) => item.value),
339
+ min,
340
+ max
341
+ ),
342
+ 1
343
+ );
344
+ const width = requestedSize ?? Math.max((max - start) / count, 1);
345
+ const bins = Array.from({ length: count }, (_, index) => ({
346
+ value: 0,
347
+ label: binLabel(start + index * width, start + (index + 1) * width, closed, format),
348
+ sourceIndices: []
349
+ }));
350
+ for (const item of regular) {
351
+ const rawIndex = closed === "r" ? Math.ceil((item.value - start) / width) - 1 : Math.floor((item.value - start) / width);
352
+ const index = Math.max(0, Math.min(rawIndex, bins.length - 1));
353
+ const bin = bins[index];
354
+ if (!bin) continue;
355
+ bin.value += 1;
356
+ bin.sourceIndices.push(item.sourceIndex);
357
+ }
358
+ result.push(...bins);
359
+ }
360
+ if (overflow !== void 0) {
361
+ result.push({
362
+ value: over.length,
363
+ label: `${closed === "l" ? "\u2265" : ">"} ${format(overflow)}`,
364
+ sourceIndices: over.map((item) => item.sourceIndex)
365
+ });
366
+ }
367
+ return result;
368
+ }
369
+ function aggregateByCategory(values, rawCategories) {
370
+ const { uniqueCategories, rowIndexesByCategory } = groupRowsByCategory(rawCategories);
371
+ return uniqueCategories.map((label) => {
372
+ const rows = rowIndexesByCategory.get(label) ?? [];
373
+ return {
374
+ value: rows.reduce((sum, row) => sum + (values[row] ?? 0), 0),
375
+ label,
376
+ sourceIndices: rows
377
+ };
378
+ });
379
+ }
380
+
381
+ export { aggregateByCategory, axisTargetIntervals, computeBoxStats, computeHistogramBins, computeLinearRegression, computeRSquared, computeStackedSeriesPlots, fitPolynomial, groupRowsByCategory, niceAxisStep, niceValueAxisBounds, resolveBlankDisplay, scottBinCount, visibleRuns };