diff --git a/packages/preview/pivot/0.1.0/.gitignore b/packages/preview/pivot/0.1.0/.gitignore new file mode 100644 index 0000000000..13ae7d1389 --- /dev/null +++ b/packages/preview/pivot/0.1.0/.gitignore @@ -0,0 +1,31 @@ +# OS +.DS_Store +Thumbs.db + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# LLM development +.claude/ +CLAUDE.md +CONTEXT.md + +# Local docs source (separate docs site) +design/ + +# Secrets +*.token +token.txt +.env +.env.* + +# Test output +tests/**/out/ +tests/**/diff/ + +# Typst build artifacts +*.pdf diff --git a/packages/preview/pivot/0.1.0/CHANGELOG.md b/packages/preview/pivot/0.1.0/CHANGELOG.md new file mode 100644 index 0000000000..05e91bdfdd --- /dev/null +++ b/packages/preview/pivot/0.1.0/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +Notable changes to pivot, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +[Semantic Versioning](https://semver.org/). Pre-1.0, a breaking change can land in +a minor release — each is flagged with a migration note. + +## [Unreleased] + +## [0.1.0] - 2026-06-28 + +First release: the byte-region family — three views over the same bytes, sharing +one field model so they never disagree on where a field starts. + +### Added + +- **`packet`** — flat protocol-header view; fields auto-flow and wrap, narrow + labels become leader callouts, with a deduplicating bit ruler. +- **`struct`** — vertical memory map; box height tracks byte size (oversized + fields capped with a break mark), hex offsets, sub-byte fields expand in place. +- **`hexdump`** — real bytes + ASCII gutter; annotations you `fill:` are + highlighted in place, and every annotation is keyed in a byte-range legend. + `data:` takes `read(f, encoding: none)` or an int array. +- Shared elements `bytes` / `bits` / `gap` / `reserved`, with `at:` (byte offset + on `bytes`, bit offset on `bits`) and `fill:`. +- **`palette`** — Okabe–Ito colour-blind-safe highlight colours, for `fill:`. +- Theming via a `theme:` dict; built on `@preview/cetz:0.5.2` (Typst ≥ 0.14). + +[Unreleased]: https://github.com/cybermallard/typst-pivot/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/cybermallard/typst-pivot/releases/tag/v0.1.0 diff --git a/packages/preview/pivot/0.1.0/LICENSE b/packages/preview/pivot/0.1.0/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/packages/preview/pivot/0.1.0/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/preview/pivot/0.1.0/NOTICE b/packages/preview/pivot/0.1.0/NOTICE new file mode 100644 index 0000000000..7ffcb45117 --- /dev/null +++ b/packages/preview/pivot/0.1.0/NOTICE @@ -0,0 +1,15 @@ +pivot +Copyright 2026 cybermallard and the pivot contributors + +Licensed under the Apache License, Version 2.0 (the "License"); see the LICENSE +file in this repository for the full terms. + +--- + +Third-party attribution + +pivot renders with CeTZ (https://github.com/cetz-package/cetz), licensed under +the GNU Lesser General Public License v3.0 or later (LGPL-3.0-or-later). CeTZ is +neither vendored nor modified by this package; the Typst compiler fetches it +independently at build time. pivot calls CeTZ's public API only and contains no +CeTZ source. diff --git a/packages/preview/pivot/0.1.0/README.md b/packages/preview/pivot/0.1.0/README.md new file mode 100644 index 0000000000..02f012c482 --- /dev/null +++ b/packages/preview/pivot/0.1.0/README.md @@ -0,0 +1,164 @@ +# pivot + +

+ CI build status + pivot v0.1.0 on Typst Universe + Typst 0.14+ +

+ +Draw diagrams for Cyber Threat Intelligence (CTI) analysis. + + + + + + + + + +
+ packet: a TCP header, narrow flags as leader callouts
+ packet · protocol header +
+ struct: a malware C2 config as a memory map
+ struct · memory map +
+ hexdump: a Gh0st RAT C2 header annotated
+ hexdump · annotated bytes +
+ +

a TCP header (narrow flags fan out as leader callouts), a malware C2 config as a memory map, and a Gh0st RAT check-in annotated in a hexdump.

+ +## Installation + +Import pivot from the preview namespace and the Typst compiler +fetches it (and CeTZ) on first build. There's no manual install step: + +```typ +#import "@preview/pivot:0.1.0": packet, struct, hexdump +``` + +## Using pivot + +Currently, there are 3 diagrams available; packet, struct, and hexdump. They share +one vocabulary — `bytes(n)`, `bits(n)`, `gap(n)`, `reserved(n)`, with `at:` (offset) +and `fill:` (highlight). You describe the entity (widths and labels); pivot derives +the offset, row, and ruler number. + +The gallery diagrams above are built from calls like these: + +A **`packet`** — the TCP header, with the sequence and acknowledgment numbers +highlighted (the narrow flag bits become leader callouts automatically): + +```typ +#import "@preview/pivot:0.1.0": packet, struct, hexdump, bytes, bits, gap, palette + +#packet( + bytes(2)[Source Port], bytes(2)[Destination Port], + bytes(4, fill: palette.blue)[Sequence Number], + bytes(4, fill: palette.blue)[Acknowledgment Number], + bits(4)[Data Offset], bits(6)[Reserved], + bits(1)[URG], bits(1)[ACK], bits(1)[PSH], bits(1)[RST], bits(1)[SYN], bits(1)[FIN], + bytes(2)[Window], + bytes(2)[Checksum], bytes(2)[Urgent Pointer], +) +``` + +**`struct`** — a malware C2 beacon header as a memory map: + +```typ +#struct( + bytes(4)[Magic], + bytes(1)[Version], bytes(1)[Command], bytes(2)[Bot ID], + bytes(4, fill: palette.orange)[Campaign Key], + gap(16)[unparsed], bytes(2)[Payload Len], +) +``` + +**`hexdump`** — a Gh0st RAT C2 check-in, fields annotated in the captured bytes: + +```typ +#hexdump( + data: read("ghost-checkin.bin", encoding: none), + bytes(5, at: 0x00, fill: palette.orange)[Magic: "Gh0st"], + bytes(4, at: 0x05, fill: palette.sky)[Total size (LE)], + bytes(4, at: 0x09, fill: palette.green)[Uncompressed size (LE)], + bytes(57, at: 0x0d, fill: palette.yellow)[zlib payload (0x78 9C)], +) +``` + + +## Diagrams + +**Available Diagrams** + +| | | +|---|---| +| **`packet`** | Flat protocol-header view — fields wrap into rows under a bit ruler; narrow labels become leader callouts. | +| **`struct`** | Vertical memory map — box height tracks byte size, hex offsets down the side, sub-byte fields expand in place. | +| **`hexdump`** | Real bytes with an ASCII gutter, fields highlighted in place and keyed in a colour legend. | + +All three share one field vocabulary (`bytes` / `bits` / `gap` / `reserved`) over +the same model, so views of the same bytes can't disagree. + +**Diagram Roadmap** + +_Alphabetical order, i.e., not the order in which they will be released._ + +| | | +|---|---| +| ATT&CK matrix | Technique coverage as a grid. | +| Attack tree | A hierarchical representation of paths an adversary could take to achieve a goal. | +| Bowtie | A event at the center, threats on the left, consequences on the right, annotated with preventive and mitigating barriers. | +| Diamond Model | The four vertices: adversary, capability, infrastructure, victim. | +| Flowchart | A step-by-step view of a process and its decision points.| +| Knowledge graph | Typed entities as nodes joined by labelled edges. | +| Pyramid of Pain | Indicator types ranked by adversary cost. | +| Sequence | A time-ordered view of interactions between parties. | +| Timelines | Events on an ordered axis — horizontal, vertical, or snaked. | + +## Accessibility + +Readability is the default. Pivot exposes `palette.[colour]` allowing you to use the 8-colour [Okabe–Ito](https://jfly.uni-koeln.de/color/) colour-blind-safe +palette: + +![pivot's colour-blind-safe palette](https://raw.githubusercontent.com/cybermallard/typst-pivot/v0.1.0/docs/img/palette.png) + +The rest of the defaults stay legible and adjustable: + +- **Inherits the document font.** Field labels use your document's font. The bit ruler + and hexdump grid pin to the bundled monospace (DejaVu Sans Mono) to keep columns aligned. +- **Sizes are theme tokens.** `label-size`, `bit-size`, and `hexdump-size` scale + up for legibility, e.g. `theme: themes.default + (label-size: 12pt)`. + +## Documentation + +Full docs are in progress. For now, +[`examples/`](https://github.com/cybermallard/typst-pivot/tree/v0.1.0/examples) has a +runnable example for every diagram. + +### Adding a caption to a diagram + +Captions come from Typst's own `figure` function. The default caption gap is a little tight, +a slightly wider `#set figure(gap: 1em)` reads better: + +```typ +#set figure(gap: 1em) // a little more room than the 0.65em default + +#figure( + packet( + bytes(2)[Source Port], bytes(2)[Destination Port], + bytes(4)[Sequence Number], + ), + caption: [TCP header (excerpt)], +) +``` + +## Built on CeTZ + +pivot renders with CeTZ, licensed under **LGPL-3.0-or-later**. CeTZ is neither +vendored nor modified; the Typst compiler fetches it independently at build time. + +## License + +[Apache-2.0](LICENSE). See [NOTICE](NOTICE) for attribution. diff --git a/packages/preview/pivot/0.1.0/lib.typ b/packages/preview/pivot/0.1.0/lib.typ new file mode 100644 index 0000000000..3798360548 --- /dev/null +++ b/packages/preview/pivot/0.1.0/lib.typ @@ -0,0 +1,12 @@ +// pivot — public API. Exports the deliberate, minimal surface. + +#import "src/field/elements.typ": bits, bytes, gap, reserved +#import "src/packet/render.typ": packet +#import "src/struct/render.typ": struct +#import "src/hexdump/render.typ": hexdump + +// Named themes. Pass `theme: themes.default + (token: value, ...)` to customise. +#import "src/theme.typ" as themes + +// The shared Okabe–Ito colour-blind-safe highlight palette, for `fill:`. +#import "src/palette.typ": palette diff --git a/packages/preview/pivot/0.1.0/src/field/elements.typ b/packages/preview/pivot/0.1.0/src/field/elements.typ new file mode 100644 index 0000000000..a4b1a6f6e9 --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/field/elements.typ @@ -0,0 +1,44 @@ +// Field element constructors — the byte-region cluster's shared vocabulary +// (packet, struct, hexdump all build from these). Each returns a plain descriptor +// dict that `model` consumes; the author supplies widths and labels, never bit +// positions. Pure. +// `at:` anchors a field to an absolute offset in the constructor's own unit: +// `bytes(.., at: k)` is byte `k`, `bits(.., at: k)` is bit `k` (the model works +// in bits, so `bytes` scales its anchor up by 8). This suits byte-oriented views +// (hexdump/struct) without a separate `unit:`. `fill:` highlights a field. `gap` +// is a dashed "unparsed" span; `reserved` is a plain empty field (reserved bits). +// Labels are optional positional trailing content (as on `gap`/`reserved`): omit +// to draw an unnamed field — e.g. `bytes(4, at: 0x10, fill: palette.orange)` +// highlights a region without a legend row. + +#let bytes(n, ..rest, at: none, fill: none) = ( + kind: "field", + width: n * 8, + label: rest.pos().at(0, default: none), + anchor: if at == none { none } else { at * 8 }, + fill: fill, +) + +#let bits(n, ..rest, at: none, fill: none) = ( + kind: "field", + width: n, + label: rest.pos().at(0, default: none), + anchor: at, + fill: fill, +) + +#let gap(n, ..rest) = ( + kind: "gap", + width: n, + label: rest.pos().at(0, default: none), + anchor: none, + fill: none, +) + +#let reserved(n, ..rest) = ( + kind: "field", + width: n, + label: rest.pos().at(0, default: none), + anchor: none, + fill: none, +) diff --git a/packages/preview/pivot/0.1.0/src/field/layout.typ b/packages/preview/pivot/0.1.0/src/field/layout.typ new file mode 100644 index 0000000000..fd9a4ca8bd --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/field/layout.typ @@ -0,0 +1,28 @@ +// layout: fields -> positioned segments, one field clipped to each row it +// touches. Pure, no cetz. `col-start`/`col-end` are 0-based columns within the +// row; `continued`/`continues` mark a field that arrived from / carries on to an +// adjacent row. `kind` and `fill` carry through for the renderer. + +#let layout(fields, bits-per-row: 32) = { + let segments = () + for f in fields { + let row-start = int(f.start / bits-per-row) + let row-end = int(f.end / bits-per-row) + for r in range(row-start, row-end + 1) { + let row-lo = r * bits-per-row + let bit-start = calc.max(f.start, row-lo) + let bit-end = calc.min(f.end, row-lo + bits-per-row - 1) + segments.push(( + kind: f.kind, + row: r, + col-start: bit-start - row-lo, + col-end: bit-end - row-lo, + label: f.label, + fill: f.at("fill", default: none), + continued: r > row-start, + continues: r < row-end, + )) + } + } + segments +} diff --git a/packages/preview/pivot/0.1.0/src/field/model.typ b/packages/preview/pivot/0.1.0/src/field/model.typ new file mode 100644 index 0000000000..f87e4abf7a --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/field/model.typ @@ -0,0 +1,44 @@ +// model: field descriptors -> fields with derived bit positions. Pure, no cetz. +// Shared by the byte-region cluster (packet/struct/hexdump). +// `end` is inclusive. Positions are derived from widths and `anchor`s, never +// authored, so the ruler can't disagree with the boxes (veracity over +// standard-conformance). An `anchor` past the running cursor leaves an implicit +// `gap` field for the skipped span. `kind` and `fill` carry through unchanged. + +#let model(descriptors) = { + let fields = () + let cursor = 0 + for d in descriptors { + // width <= 0 can't be drawn; this `panic` names the field for the user. + // It is not unit-tested (Typst has no try/catch) — trusted by inspection. + assert( + d.width > 0, + message: "field " + repr(d.label) + ": width must be > 0", + ) + + let anchor = d.at("anchor", default: none) + let start = if anchor != none { anchor } else { cursor } + if start > cursor { + fields.push(( + kind: "gap", + start: cursor, + end: start - 1, + label: none, + fill: none, + )) + } + + let end = start + d.width - 1 + fields.push(( + kind: d.kind, + start: start, + end: end, + label: d.label, + fill: d.at("fill", default: none), + )) + // A backwards `anchor` (an intentional overlap) places one field without + // rewinding the flow — later auto-flow fields resume from the furthest point. + cursor = calc.max(cursor, end + 1) + } + fields +} diff --git a/packages/preview/pivot/0.1.0/src/hexdump/layout.typ b/packages/preview/pivot/0.1.0/src/hexdump/layout.typ new file mode 100644 index 0000000000..874f3f7c4b --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/hexdump/layout.typ @@ -0,0 +1,65 @@ +// layout: raw bytes -> rows for a hex dump, plus the pure formatting leaves the +// renderer needs. No cetz. A hex dump is byte-granular: each row carries its +// byte offset and the byte values that fall in it; the renderer formats the hex +// and ASCII columns and (later) overlays field annotations. `per` is the row +// width in bytes (16 is the classic xxd / `hexdump -C` width). + +#let to-bytes(data) = { + // Accept Typst `bytes` (e.g. `read(file, encoding: none)`) or an int array. + if type(data) == bytes { array(data) } else { data } +} + +#let rows(data, per: 16) = { + let b = to-bytes(data) + let out = () + let r = 0 + while r * per < b.len() { + let lo = r * per + out.push((offset: lo, bytes: b.slice(lo, calc.min(lo + per, b.len())))) + r += 1 + } + out +} + +// Two upper-case hex digits, zero-padded: 13 -> "0D", 255 -> "FF". +#let hex-byte(n) = { + let s = upper(str(n, base: 16)) + "0" * (2 - s.len()) + s +} + +// Zero-padded upper-case hex offset of a fixed digit width (default 8). +#let hex-offset(n, width: 8) = { + let s = upper(str(n, base: 16)) + "0" * calc.max(0, width - s.len()) + s +} + +// Printable ASCII renders as itself; everything else as a placeholder dot. +#let printable(n) = n >= 0x20 and n <= 0x7e +#let ascii(n) = if printable(n) { str.from-unicode(n) } else { "." } + +// Lay `n` legend entries into balanced columns: aim for `per-col` a column, but +// never exceed `max-cols` (so 1 column up to `per-col`, 2 up to `2*per-col`, 3 +// beyond — then columns just grow taller). Sizes differ by at most one, filled +// top-down then left-to-right, so there's no lonely trailing column. Returns +// `(cols, rows, positions)` where `positions.at(k) = (col, row)` for entry `k`. +#let legend-columns(n, per-col: 3, max-cols: 3) = { + if n <= 0 { + (cols: 0, rows: 0, positions: ()) + } else { + let cols = calc.min(max-cols, calc.ceil(n / per-col)) + let base = int(n / cols) + let extra = calc.rem(n, cols) + let positions = () + let c = 0 + let r = 0 + for _i in range(0, n) { + positions.push((c, r)) + r += 1 + if r >= base + (if c < extra { 1 } else { 0 }) { + c += 1 + r = 0 + } + } + (cols: cols, rows: calc.ceil(n / cols), positions: positions) + } +} diff --git a/packages/preview/pivot/0.1.0/src/hexdump/render.typ b/packages/preview/pivot/0.1.0/src/hexdump/render.typ new file mode 100644 index 0000000000..940207a019 --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/hexdump/render.typ @@ -0,0 +1,202 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "../field/model.typ": model +#import "../field/layout.typ": layout +#import "layout.typ": ascii, hex-byte, hex-offset, legend-columns, rows + +// hexdump: the byte-region cluster's annotation view — real bytes laid out +// `per` to a row with an ASCII gutter, with field annotations highlighted in +// place and named in a legend below. `data` is Typst `bytes` (typically +// `read(file, encoding: none)`) or a plain int array; annotations are the shared +// `bytes`/`bits` constructors, their byte ranges rounded to whole cells. Returns +// content. +#let hexdump( + ..annotations, + data: none, + per: 16, + theme: theme-mod.default, +) = context { + assert( + data != none, + message: "hexdump: `data:` is required (e.g. read(file, encoding: none))", + ) + let rs = rows(data, per: per) + + // Annotations -> fields -> per-row segments (a range crossing a row break + // splits into one segment per row). + let fields = model(annotations.pos()) + let segs = layout(fields, bits-per-row: per * 8) + // In-grid highlighting is opt-in: a field is drawn only when it has a `fill:`. + // A hexdump has no per-field box to fall back on (unlike `packet`/`struct`), so + // colour is the sole in-grid marker. The fill is honoured whether or not the + // field is labelled (the label only names it in the legend); an unfilled field + // isn't dropped either — it's still listed in the legend below. + let field-segs = segs.filter(s => s.kind == "field" and s.fill != none) + + // One legend entry per distinct annotated field, first-appearance order, + // carrying its byte range and its `fill:` colour. The colour may be `none`: an + // unfilled annotation is still listed (we never drop what the author passed) — + // it just gets no swatch here and no in-grid highlight above. + let legend = () + for f in fields { + let is-new = ( + f.kind == "field" + and f.label != none + and legend.find(e => e.label == f.label) == none + ) + if is-new { + legend.push(( + label: f.label, + color: f.fill, + lo: int(f.start / 8), + hi: int(f.end / 8), + )) + } + } + + let mono = theme.hexdump-font + let size = theme.hexdump-size + let legend-size = theme.hexdump-legend-size + let line-h = theme.hexdump-line / 1cm + let off-fill = theme.hexdump-offset-color + let text-fill = theme.hexdump-text-color + let legend-gap = theme.hexdump-legend-gap / 1cm + let swatch = theme.hexdump-swatch / 1cm + let label-pad = theme.label-pad / 1cm + let per-col = theme.hexdump-legend-rows + let max-cols = theme.hexdump-legend-cols + let legend-col-gap = theme.hexdump-legend-col-gap / 1cm + + // The grid is monospace, so one glyph advance sizes every column; measure it + // once. `mono-text` is captured before the canvas so `text` stays the builtin. + let char-w = measure(text(font: mono, size: size, "0")).width / 1cm + let mono-text = (body, fill) => text(font: mono, size: size, fill: fill, body) + // Legend is set a notch smaller than the grid (its own size token). + let mono-legend = (body, fill) => text( + font: mono, + size: legend-size, + fill: fill, + body, + ) + + // Compact hex byte range for a legend row: "0x00–0x01" (single byte: "0x3C"). + // `range-w` reserves a column so the names line up after the ranges. + let range-of = e => { + let lo = "0x" + hex-offset(e.lo, width: 2) + if e.lo == e.hi { lo } else { lo + "–0x" + hex-offset(e.hi, width: 2) } + } + let range-w = calc.max( + 0, + ..legend.map(e => measure(mono-legend(range-of(e), off-fill)).width / 1cm), + ) + // Widest name, so a wrapped second column starts past the first's labels. + let label-w = calc.max( + 0, + ..legend.map(e => measure(text(size: legend-size, e.label)).width / 1cm), + ) + + // Lay the legend into balanced columns, capped at `max-cols` (see layout.typ). + let leg-pos = legend-columns( + legend.len(), + per-col: per-col, + max-cols: max-cols, + ).positions + + // A byte's trailing separator: none after the last, a wider gap every 8 bytes. + let sep = j => if j == per - 1 { "" } else if calc.rem(j + 1, 8) == 0 { + " " + } else { " " } + // Glyph column where byte `j`'s hex pair starts, within the hex block. + let hpos = j => range(0, j).map(k => 2 + sep(k).len()).sum(default: 0) + + // Column origins, in glyph advances from the left: the offset column (as wide + // as `hex-offset` renders) then a two-glyph gap. The hex block is padded to a + // full row so the ASCII column never shifts on a short final row. + let hex-x = (hex-offset(0).len() + 2) * char-w + let full-hex = range(0, per).map(j => "00" + sep(j)).join() + let ascii-x = hex-x + (full-hex.len() + 1) * char-w + + cetz.canvas({ + import cetz.draw: * + + // Highlights first, behind the glyphs. A byte range covers whole hex cells + // (with the separators between them) and the matching ASCII chars; a + // multi-row range tiles across the row bands. + let half = line-h / 2 + for s in field-segs { + let y = -s.row * line-h + let a = int(s.col-start / 8) + let b = int(s.col-end / 8) + let col = s.fill + rect( + (hex-x + hpos(a) * char-w, y - half), + (hex-x + (hpos(b) + 2) * char-w, y + half), + fill: col, + stroke: none, + ) + rect( + (ascii-x + (1 + a) * char-w, y - half), + (ascii-x + (2 + b) * char-w, y + half), + fill: col, + stroke: none, + ) + } + + // The dump rows, on top of the highlights. + for (i, row) in rs.enumerate() { + let y = -i * line-h + content( + (0, y), + mono-text(hex-offset(row.offset), off-fill), + anchor: "west", + ) + let cells = range(0, per) + .map(j => ( + ( + if j < row.bytes.len() { hex-byte(row.bytes.at(j)) } else { " " } + ) + + sep(j) + )) + .join() + content((hex-x, y), mono-text(cells, text-fill), anchor: "west") + let glyphs = row.bytes.map(ascii).join() + content( + (ascii-x, y), + mono-text("|" + glyphs + "|", text-fill), + anchor: "west", + ) + } + + // Legend: colour swatch + byte range + field name. Entries fill a column + // top-down, then wrap into the next column after `per-col` (e.g. >3 -> two + // columns of three), so a long field list stays compact. + let base-y = -rs.len() * line-h - legend-gap + let label-x = swatch + label-pad + range-w + label-pad + let col-width = label-x + label-w + legend-col-gap + for (k, e) in legend.enumerate() { + let (ci, ri) = leg-pos.at(k) + let lx = ci * col-width + let ly = base-y - ri * line-h + // A swatch only for a filled field; an unfilled one still lists its range + // and label, the swatch column left blank so the columns stay aligned. + if e.color != none { + rect( + (lx, ly - swatch / 2), + (lx + swatch, ly + swatch / 2), + fill: e.color, + stroke: none, + ) + } + content( + (lx + swatch + label-pad, ly), + mono-legend(range-of(e), off-fill), + anchor: "west", + ) + content( + (lx + label-x, ly), + text(size: legend-size, fill: text-fill, e.label), + anchor: "west", + ) + } + }) +} diff --git a/packages/preview/pivot/0.1.0/src/packet/render.typ b/packages/preview/pivot/0.1.0/src/packet/render.typ new file mode 100644 index 0000000000..fdd8a34dee --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/packet/render.typ @@ -0,0 +1,340 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "../field/model.typ": model +#import "../field/layout.typ": layout + +// packet: the entry function. Collects element descriptors, runs the pure +// model -> layout pipeline, and draws the segments with cetz. Returns content. +// +// `callout` controls where narrow fields' labels go: +// "gap" — fanned into the enlarged gap below their row (orthogonal leaders) +// "left" — left-aligned column, leaders fanning left/right (orthogonal) +// `ruler` controls the bit-edge numbers: +// "dedup" — a boundary number is shown only the first time (top-down) it +// appears; repeats are hidden and rows that introduce no new boundary +// lose their number strip and tuck closer (default) +// "full" — every box edge is numbered on every row +#let packet( + ..args, + bits-per-row: 32, + callout: "left", + ruler: "dedup", + theme: theme-mod.default, +) = context { + let fields = model(args.pos()) + let segments = layout(fields, bits-per-row: bits-per-row) + + // Capture tokens into differently-named locals BEFORE `import cetz.draw: *`. + let cell-stroke = theme.stroke + let cell-fill = theme.fill + let gap-stroke = theme.gap-stroke + let gap-fill = theme.gap-fill + let leader-stroke = theme.leader-stroke + let bit-w = theme.bit-width / 1cm + let row-h = theme.row-height / 1cm + let row-gap = theme.row-gap / 1cm + let col-gap = theme.col-gap / 1cm + let strip = theme.strip / 1cm + let label-size = theme.label-size + let bit-size = theme.bit-size + let bit-fill = theme.bit-color + let bit-font = theme.bit-font + let label-pad = theme.label-pad / 1cm + let callout-drop = theme.callout-drop / 1cm + let callout-bottom = theme.callout-bottom / 1cm + let callout-spacing = theme.callout-spacing / 1cm + let callout-gap = theme.callout-gap / 1cm + let line-h = theme.callout-line-height / 1cm + let stub = theme.callout-stub / 1cm + let side-gap = theme.callout-side-gap / 1cm + let label-gap = theme.callout-label-pad / 1cm + let gap-drop = theme.callout-gap-drop / 1cm + let lane-top = theme.callout-gap-lane-top / 1cm + let lane-bot = theme.callout-gap-lane-bot / 1cm + let gap-leader = theme.callout-gap-leader / 1cm + + // Measure each segment's label once. "narrow" (label wider than its box -> it + // needs a callout) and the label width are then O(1) lookups, reused by the + // filters, the crowding check, the callout list, and the draw loop below. + let meas = (:) + for s in segments { + let w = if s.kind == "gap" or s.label == none { + 0.0 + } else { + measure(text(size: label-size, s.label)).width / 1cm + } + let box-w = (s.col-end - s.col-start + 1) * bit-w - col-gap - 2 * label-pad + meas.insert( + str(s.row) + "/" + str(s.col-start), + (w: w, narrow: s.kind != "gap" and s.label != none and w > box-w), + ) + } + let is-narrow = s => meas.at(str(s.row) + "/" + str(s.col-start)).narrow + let width-of = s => meas.at(str(s.row) + "/" + str(s.col-start)).w + let callout-rows = segments.filter(is-narrow).map(s => s.row).dedup() + let max-row = calc.max(0, ..segments.map(s => s.row)) + let count-in = r => segments.filter(s => s.row == r and is-narrow(s)).len() + + // A narrow field's horizontal centre (independent of the vertical layout). + let cx-of = s => (s.col-start + s.col-end + 1) * bit-w / 2 + + // The compact split's LEFT label column: hug the leftmost field, but clamp so + // the widest label can't cross the frame's left edge (x = 0). Shared by the + // crowding test and the layout so both reason about the same geometry. `lg` is + // a non-empty list of items with `.cx` (field centre) and `.w` (label width). + let left-col-x = lg => calc.max( + calc.min(..lg.map(c => c.cx)) - side-gap, + calc.max(..lg.map(c => c.w)) + label-gap, + ) + + // Per callout row: would the compact left/right split cross a label? It does + // when a left-column field's drop falls within a higher label's text — i.e. the + // row is crowded with thin segments. Such rows switch to a single column placed + // OUTSIDE the frame (the struct layout), crossing-free by construction. + let crowded = (:) + for r in callout-rows { + let info = segments + .filter(s => s.row == r and is-narrow(s)) + .sorted(key: s => s.col-start) + .map(s => (cx: cx-of(s), w: width-of(s))) + let left = info.slice(0, calc.ceil(info.len() / 2)) + // Predict the compact-split LEFT layout: labels right-aligned at + // `lx - label-gap`; a field left of `lx` drops a short straight stub. Such a + // stub crosses an earlier label when the field's centre falls within that + // label's text span. (A field right of `lx` turns in from beyond every label, + // and the right group's labels sit beyond every field, so neither can cross.) + // A row that would cross switches to the crowded single-column layout below. + let cross = false + if left.len() >= 2 { + let lx = left-col-x(left) + let label-right = lx - label-gap + for j in range(1, left.len()) { + if left.at(j).cx < lx { + for i in range(0, j) { + if ( + left.at(j).cx >= label-right - left.at(i).w + and left.at(j).cx <= label-right + ) { cross = true } + } + } + } + } + crowded.insert(str(r), cross) + } + + // Height of the annotation band below a callout row. A crowded row uses one + // tall column; otherwise the taller of the split's two columns sets the height. + let band = r => { + if callout != "left" { + callout-gap + } else if crowded.at(str(r), default: false) { + callout-drop + (count-in(r) - 1) * line-h + callout-bottom + } else { + let half = calc.ceil(count-in(r) / 2) + let lanes = calc.max(half, count-in(r) - half) + callout-drop + (lanes - 1) * line-h + callout-bottom + } + } + + // Ruler de-duplication. A boundary is keyed by (position, number) so a right + // edge `15` and a left edge `16` near the same x stay distinct. In "dedup" + // mode a key is shown only the first time it appears top-down; later repeats + // hide, and a row that introduces none keeps no number strip. "full" shows all. + let seen = () + let show-num = (:) + let row-has-num = (:) + for r in range(0, max-row + 1) { + let any = false + for s in segments.filter(s => s.row == r).sorted(key: s => s.col-start) { + let lk = (s.col-start, s.col-start) + let rk = (s.col-end + 1, s.col-end) + let sl = ruler == "full" or not seen.contains(lk) + let sr = ruler == "full" or not seen.contains(rk) + if not seen.contains(lk) { seen.push(lk) } + if not seen.contains(rk) { seen.push(rk) } + show-num.insert(str(r) + "/" + str(s.col-start), (left: sl, right: sr)) + any = any or sl or sr + } + row-has-num.insert(str(r), any) + } + + // Precompute each row's box-top y (rows stack downward; up is positive). A row + // with no visible ruler numbers needs no top strip, so it tucks closer. + let box-tops = () + let row-pad = () + let yacc = 0.0 + for r in range(0, max-row + 1) { + let pad = if row-has-num.at(str(r)) { strip } else { 0.0 } + row-pad.push(pad) + box-tops.push(-(yacc + pad)) + let gap-below = if callout-rows.contains(r) { band(r) } else { row-gap } + yacc += pad + row-h + gap-below + } + + // Geometry of a segment's box, and the narrow ones' callout data (widths + // measured here, in `context`, so leaders can stop just past each label). + let seg-box = s => { + let x0 = s.col-start * bit-w + col-gap / 2 + let x1 = (s.col-end + 1) * bit-w - col-gap / 2 + let top = box-tops.at(s.row) + (x0: x0, x1: x1, top: top, bot: top - row-h) + } + let callouts = segments + .filter(is-narrow) + .map(s => { + let b = seg-box(s) + ( + row: s.row, + cx: (b.x0 + b.x1) / 2, + by: b.bot, + label: s.label, + w: width-of(s), + ) + }) + + cetz.canvas({ + import cetz.draw: * + + for s in segments { + let b = seg-box(s) + let is-gap = s.kind == "gap" + let box-stroke = if is-gap { gap-stroke } else { cell-stroke } + let box-fill = if is-gap { gap-fill } else if s.fill != none { + s.fill + } else { cell-fill } + rect((b.x0, b.bot), (b.x1, b.top), stroke: box-stroke, fill: box-fill) + + if (not is-gap) and s.label != none and not is-narrow(s) { + content(((b.x0 + b.x1) / 2, (b.top + b.bot) / 2), text( + size: label-size, + s.label, + )) + } + + let ny = b.top + row-pad.at(s.row) / 2 + let sn = show-num.at(str(s.row) + "/" + str(s.col-start)) + if sn.left { + content( + (b.x0, ny), + text(size: bit-size, font: bit-font, fill: bit-fill, str( + s.col-start, + )), + anchor: "west", + ) + } + if sn.right { + content( + (b.x1, ny), + text(size: bit-size, font: bit-font, fill: bit-fill, str(s.col-end)), + anchor: "east", + ) + } + } + + for r in callouts.map(c => c.row).dedup() { + let group = callouts.filter(c => c.row == r).sorted(key: c => c.cx) + let n = group.len() + let row-bot = box-tops.at(r) - row-h + + if callout == "left" and crowded.at(str(r), default: false) { + // Crowded row: one column OUTSIDE the frame (the struct layout). Labels + // right-aligned just left of the leftmost field, drops stay inside the + // frame, so a drop can never cut a label; leftmost field on top keeps the + // orthogonal leaders un-crossed. + let gutter = calc.min(..group.map(c => c.cx)) - side-gap + for (i, c) in group.enumerate() { + let y = row-bot - callout-drop - i * line-h + line((c.cx, c.by), (c.cx, y), (gutter, y), stroke: leader-stroke) + content( + (gutter - label-gap, y), + text(size: label-size, c.label), + anchor: "east", + ) + } + } else if callout == "left" { + // Compact split into two columns, each anchored to its OWN fields: the + // left half fans left to just past the leftmost field, the right half + // fans right past the rightmost — so neither group strands its labels at + // the frame edge. Lane order (outermost field on top per side) keeps the + // orthogonal leaders un-crossed. Used when the row would not crowd. + let mid = calc.ceil(n / 2) + let left-grp = group.slice(0, mid) + let right-grp = group.slice(mid) + + // Left column (see `left-col-x`): hug the leftmost field, clamped to the + // frame edge. A field hard against the edge then gets a straight stub + // rather than a leader turning rightward through its own label. + let left-x = left-col-x(left-grp) + for (i, c) in left-grp.enumerate() { + let y = row-bot - callout-drop - i * line-h + // A field to the right of the column turns left onto its label; a field + // sitting over its own label (clamped column) gets a short straight + // stub instead, so the leader never runs through the text. + if c.cx >= left-x { + line((c.cx, c.by), (c.cx, y), (left-x, y), stroke: leader-stroke) + } else { + line((c.cx, c.by), (c.cx, y + stub), stroke: leader-stroke) + } + content( + (left-x - label-gap, y), + text(size: label-size, c.label), + anchor: "east", + ) + } + + // Right half — empty when the row has a single narrow field (it falls in + // `left-grp`), so guard against `calc.max` of nothing. Unlike the left, it + // extends outward unclamped: a long label on a far-right field may spill + // past the frame's right edge, the accepted fallback when a label has + // nowhere else to go. + if right-grp.len() > 0 { + let right-x = calc.max(..right-grp.map(c => c.cx)) + side-gap + let right-n = right-grp.len() + for (i, c) in right-grp.enumerate() { + let y = row-bot - callout-drop - (right-n - 1 - i) * line-h + line((c.cx, c.by), (c.cx, y), (right-x, y), stroke: leader-stroke) + content( + (right-x + label-gap, y), + text(size: label-size, c.label), + anchor: "west", + ) + } + } + } else { + // In-gap fan: orthogonal Z leaders, label row near the band's bottom. + let centroid = group.map(c => c.cx).sum() / n + let start-lx = centroid - (n - 1) * callout-spacing / 2 + let items = group + .enumerate() + .map(((i, c)) => ( + cx: c.cx, + by: c.by, + label: c.label, + lx: start-lx + i * callout-spacing, + )) + let label-y = row-bot - callout-gap + gap-drop + let lane-hi = row-bot - lane-top + let lane-lo = label-y + lane-bot + let order = items + .enumerate() + .sorted(key: ((j, it)) => -calc.abs(it.lx - centroid)) + for (rank, (j, it)) in order.enumerate() { + let t = if n <= 1 { 0 } else { rank / (n - 1) } + let lane = lane-lo + t * (lane-hi - lane-lo) + line( + (it.cx, row-bot), + (it.cx, lane), + (it.lx, lane), + (it.lx, label-y + gap-leader), + stroke: leader-stroke, + ) + content( + (it.lx, label-y), + text(size: label-size, it.label), + anchor: "north", + ) + } + } + } + }) +} diff --git a/packages/preview/pivot/0.1.0/src/palette.typ b/packages/preview/pivot/0.1.0/src/palette.typ new file mode 100644 index 0000000000..a185dadab5 --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/palette.typ @@ -0,0 +1,15 @@ +// palette: the shared Okabe–Ito colour-blind-safe qualitative palette, lightened +// into highlight backgrounds that keep black text legible. One source of truth +// for field-highlight colour across all three diagrams — reach for a colour by +// name in an explicit `fill:`, e.g. `fill: palette.orange`. Pure; no cetz. + +#let palette = ( + orange: rgb("#E69F00").lighten(45%), + sky: rgb("#56B4E9").lighten(45%), + green: rgb("#009E73").lighten(45%), + yellow: rgb("#F0E442").lighten(45%), + blue: rgb("#0072B2").lighten(45%), + vermillion: rgb("#D55E00").lighten(45%), + purple: rgb("#CC79A7").lighten(45%), + grey: rgb("#000000").lighten(80%), +) diff --git a/packages/preview/pivot/0.1.0/src/struct/layout.typ b/packages/preview/pivot/0.1.0/src/struct/layout.typ new file mode 100644 index 0000000000..8ea3e6df65 --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/struct/layout.typ @@ -0,0 +1,91 @@ +// struct/layout.typ — field model -> vertically stacked entries for the struct +// (memory-map) renderer. Pure: no cetz, no theme. +// +// Two entry types: +// "box" — a whole-byte-aligned field: one vertical box, height proportional +// to byte size (floored to `min-height`, capped at `max-height`; +// `clamped` flags the cap for a break mark). +// "bits" — a byte-run carved into sub-byte fields: one strip the height of its +// byte-run, holding `cells` for the renderer to subdivide +// horizontally. Consecutive fields are grouped until the run returns +// to a byte boundary, so a field that straddles a byte (and any whole +// bytes it pulls in) stays in one strip — true to real layouts like +// IPv4's 3 flag bits + 13-bit fragment offset. +// +// `scale`/`min-height`/`max-height`/`gap` are canvas units from the renderer. +// `start`/`size` stay in bits; offset formatting is the renderer's job. + +#let _box-height(bits, scale, min-height, max-height) = { + let raw = (bits / 8) * scale + ( + height: calc.max(min-height, calc.min(max-height, raw)), + clamped: raw > max-height, + ) +} + +#let layout( + fields, + scale: 0.3, + min-height: 0.55, + max-height: 2.2, + gap: 0.0, +) = { + let entries = () + let top = 0.0 + let i = 0 + let n = fields.len() + while i < n { + let f = fields.at(i) + let whole = calc.rem(f.start, 8) == 0 and calc.rem(f.end + 1, 8) == 0 + if whole { + let size = f.end - f.start + 1 + let h = _box-height(size, scale, min-height, max-height) + entries.push(( + type: "box", + kind: f.kind, + label: f.label, + fill: f.at("fill", default: none), + start: f.start, + size: size, + top: top, + height: h.height, + clamped: h.clamped, + )) + top += h.height + gap + i += 1 + } else { + // accumulate a bit-group until the run returns to a byte boundary + let group-start = calc.quo(f.start, 8) * 8 + let cells = () + while i < n { + let g = fields.at(i) + cells.push(( + kind: g.kind, + label: g.label, + fill: g.at("fill", default: none), + start: g.start, + size: g.end - g.start + 1, + )) + i += 1 + if calc.rem(g.end + 1, 8) == 0 { break } + } + let last = cells.last() + let size = last.start + last.size - group-start + // bit-strips are never height-capped: a multi-byte bit-run is rare, and a + // break mark across a subdivided strip would be awkward — so they render at + // true (floored) height. Model and renderer agree: bits never clamp. + let height = calc.max(min-height, (size / 8) * scale) + entries.push(( + type: "bits", + start: group-start, + size: size, + top: top, + height: height, + clamped: false, + cells: cells, + )) + top += height + gap + } + } + entries +} diff --git a/packages/preview/pivot/0.1.0/src/struct/render.typ b/packages/preview/pivot/0.1.0/src/struct/render.typ new file mode 100644 index 0000000000..34008b9a63 --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/struct/render.typ @@ -0,0 +1,266 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "../field/model.typ": model +#import "layout.typ": layout + +// Hex string for a non-negative integer (no "0x" prefix). Typst markup has no +// built-in int -> hex, so build it. +#let _hex(n) = { + if n == 0 { + return "0" + } + let digits = "0123456789ABCDEF" + let s = "" + let v = n + while v > 0 { + s = digits.at(calc.rem(v, 16)) + s + v = calc.quo(v, 16) + } + s +} + +// struct: the byte-region cluster's vertical "memory map" view. Fields stack +// top-down with box height proportional to byte size (see struct/layout); byte +// offsets run down the left edge, sizes down the right. Same element vocabulary +// as packet (`bytes`/`bits`/`gap`/`reserved`); no row-wrapping. Returns content. +#let struct(..args, theme: theme-mod.default) = context { + let fields = model(args.pos()) + + // Capture tokens into renamed locals BEFORE `import cetz.draw: *` shadows them. + let cell-stroke = theme.stroke + let cell-fill = theme.fill + let gap-stroke = theme.gap-stroke + let gap-fill = theme.gap-fill + let label-size = theme.label-size + let meta-size = theme.bit-size + let meta-fill = theme.bit-color + let meta-font = theme.bit-font + let box-w = theme.struct-width / 1cm + let scale = theme.struct-byte-height / 1cm + let min-h = theme.struct-min-height / 1cm + let max-h = theme.struct-max-height / 1cm + let side-gap = theme.struct-offset-gap / 1cm + let row-gap = theme.row-gap / 1cm + let col-gap = theme.col-gap / 1cm + let break-amp = theme.struct-break-amp / 1cm + let break-pitch = theme.struct-break-pitch / 1cm + let label-pad = theme.label-pad / 1cm + let leader-stroke = theme.leader-stroke + let callout-drop = theme.callout-drop / 1cm + let callout-line-h = theme.callout-line-height / 1cm + let callout-bottom = theme.callout-bottom / 1cm + + let entries = layout( + fields, + scale: scale, + min-height: min-h, + max-height: max-h, + gap: row-gap, + ) + // Pad offsets to the width of the largest (the struct's end) so they read as + // aligned addresses: 0x00, 0x04, ... 0x10. + let pad-width = if entries.len() > 0 { + _hex(calc.quo(entries.last().start + entries.last().size, 8)).len() + } else { + 1 + } + + cetz.canvas({ + import cetz.draw: * + let x0 = 0.0 + let x1 = box-w + + // a byte offset, formatted hex with a `:bit` suffix for sub-byte starts + let off-label = bitpos => { + let byte = calc.quo(bitpos, 8) + let bit = calc.rem(bitpos, 8) + let h = _hex(byte) + while h.len() < pad-width { h = "0" + h } + "0x" + h + if bit != 0 { ":" + str(bit) } else { "" } + } + + let extra = 0.0 + for (i, e) in entries.enumerate() { + let top = -(e.top + extra) + let bot = -(e.top + e.height + extra) + + if e.type == "box" { + let is-gap = e.kind == "gap" + let box-stroke = if is-gap { gap-stroke } else { cell-stroke } + let box-fill = if is-gap { + gap-fill + } else if e.fill != none { + e.fill + } else { + cell-fill + } + // A clamped (oversized) field is capped below its true size, so its + // bottom edge becomes a torn zigzag "break mark" — the height is drawn + // short on purpose, and this says so. The size label still shows true size. + if e.clamped { + let n = 2 * int(calc.max(2, calc.round((x1 - x0) / break-pitch))) + let step = (x1 - x0) / n + let zig = range(0, n + 1).map(k => ( + x1 - k * step, + if calc.rem(k, 2) == 0 { bot } else { bot + break-amp }, + )) + line( + (x0, top), + (x1, top), + ..zig, + close: true, + stroke: box-stroke, + fill: box-fill, + ) + } else { + rect((x0, bot), (x1, top), stroke: box-stroke, fill: box-fill) + } + // Label placement, in order of preference: centred on one line if it + // fits; wrapped to at most two lines inside the box if it doesn't; and + // only when even two lines won't fit (too long, or too tall for the box) + // does it move to a leader callout centred BELOW the box. The callout + // label is centred on its leader, so the diagram itself stays centred and + // the offset column stays put on the left. + if e.label != none { + let cx = (x0 + x1) / 2 + let mid-y = (top + bot) / 2 + let avail-w = (x1 - x0) - 2 * label-pad + let nat-w = measure(text(size: label-size, e.label)).width / 1cm + if nat-w <= avail-w { + content((cx, mid-y), text(size: label-size, e.label)) + } else { + // wrap to the box width and see if it lands within two lines and the + // box height; a reference two-line block sets the line-count ceiling. + // Disable justification: a wrapped label must not inherit the + // document's `par(justify: true)`, which would stretch a line with + // few break points into huge inter-word gaps. + let wrapped = box(width: avail-w * 1cm, { + set par(justify: false) + align(center, text(size: label-size, e.label)) + }) + let wh = measure(wrapped).height / 1cm + let two-line = ( + measure(box( + width: avail-w * 1cm, + text(size: label-size)[A\ A], + )).height + / 1cm + ) + let avail-h = (top - bot) - 2 * label-pad + if wh <= two-line + 0.01 and wh <= avail-h { + content((cx, mid-y), wrapped) + } else { + // too long even for two lines: drop a leader and hang the same + // wrapped block (centred, box width) below the box, so the callout + // stays contained and centred rather than sprawling as one line. + let ly = bot - callout-drop + line((cx, bot), (cx, ly), stroke: leader-stroke) + content((cx, ly), wrapped, anchor: "north") + extra += callout-drop + wh + callout-bottom + } + } + } + } else { + // a bit-carved byte-run: one strip subdivided horizontally into cells. + // A cell shows its name inline if it fits, else its bit number and a + // leader callout below (a 1-bit cell can't hold a name). + let callouts = () + let last-j = e.cells.len() - 1 + for (j, c) in e.cells.enumerate() { + let fcx0 = x0 + (c.start - e.start) / e.size * (x1 - x0) + let fcx1 = x0 + (c.start + c.size - e.start) / e.size * (x1 - x0) + // float the cells by `col-gap`, but keep the strip's outer edges flush + // with the boxes above/below (only inset the internal boundaries). + let cx0 = fcx0 + if j == 0 { 0.0 } else { col-gap / 2 } + let cx1 = fcx1 - if j == last-j { 0.0 } else { col-gap / 2 } + let cgap = c.kind == "gap" + rect( + (cx0, bot), + (cx1, top), + stroke: if cgap { gap-stroke } else { cell-stroke }, + fill: if cgap { gap-fill } else if c.fill != none { c.fill } else { + cell-fill + }, + ) + let mid = ((cx0 + cx1) / 2, (top + bot) / 2) + let fits = ( + c.label != none + and measure(text(size: label-size, c.label)).width / 1cm + <= (cx1 - cx0) - 2 * label-pad + ) + if fits { + content(mid, text(size: label-size, c.label)) + } else { + content( + mid, + text(size: meta-size, font: meta-font, fill: meta-fill, str( + c.start - e.start, + )), + ) + if c.label != none { + callouts.push((cx: (cx0 + cx1) / 2, label: c.label)) + } + } + } + // names that did not fit: stacked in a band below the strip, ordered + // left-to-right so the orthogonal drop-then-turn leaders never cross. + let m = callouts.len() + if m > 0 { + // labels live OUTSIDE the frame (left of x0, right-aligned at the same + // gutter as the offsets); leaders drop inside the frame then turn out + // to them. Drops stay at x >= x0, labels at x < x0, so a drop can never + // cut through a label's text. + let gutter = x0 - side-gap + for (k, c) in callouts.sorted(key: it => it.cx).enumerate() { + let ly = bot - callout-drop - k * callout-line-h + line((c.cx, bot), (c.cx, ly), (gutter, ly), stroke: leader-stroke) + content( + (gutter, ly), + text(size: label-size, c.label), + anchor: "east", + ) + } + extra += callout-drop + (m - 1) * callout-line-h + callout-bottom + } + } + + // offset at the boundary it marks (left column): centred in the gap above + // each entry, except the first, which has no gap and sits at its top edge. + let off-y = if i == 0 { top } else { top + row-gap / 2 } + content( + (x0 - side-gap, off-y), + text(size: meta-size, font: meta-font, fill: meta-fill, off-label( + e.start, + )), + anchor: "east", + ) + + // size, centred on the box (right column) + let size-str = if calc.rem(e.size, 8) == 0 { + str(calc.quo(e.size, 8)) + " B" + } else { + str(e.size) + " b" + } + content( + (x1 + side-gap, (top + bot) / 2), + text(size: meta-size, font: meta-font, fill: meta-fill, size-str), + anchor: "west", + ) + } + + // closing offset: the byte just past the last field (the struct's end) + if entries.len() > 0 { + let last = entries.last() + content( + (x0 - side-gap, -(last.top + last.height + extra)), + text( + size: meta-size, + font: meta-font, + fill: meta-fill, + off-label(last.start + last.size), + ), + anchor: "east", + ) + } + }) +} diff --git a/packages/preview/pivot/0.1.0/src/theme.typ b/packages/preview/pivot/0.1.0/src/theme.typ new file mode 100644 index 0000000000..75f86a7d12 --- /dev/null +++ b/packages/preview/pivot/0.1.0/src/theme.typ @@ -0,0 +1,61 @@ +// Style tokens. Render code reads tokens from a theme dict; no style literals +// live in draw code. `default` is the typeset baseline: monochrome line-art +// (RFC style), which is colour-blind-safe by construction — colour only enters +// when the author sets an explicit `fill:` on a field. + +#let default = ( + bit-width: 0.42cm, // width of one bit column + row-height: 0.8cm, // height of a field box + row-gap: 0.28cm, // vertical space between rows (floating boxes) + col-gap: 0.12cm, // horizontal space between adjacent fields (floating boxes) + strip: 0.4cm, // band above each row, holding the bit-edge numbers + stroke: 0.6pt + black, // box border + fill: none, // box fill (overridden per-field by `fill:`) + gap-stroke: (paint: luma(55%), thickness: 0.5pt, dash: "dashed"), // gap border + gap-fill: luma(96%), // gap (unparsed-span) fill + label-size: 9pt, // field-label text size + bit-size: 7pt, // bit-ruler number size + bit-color: luma(40%), // bit-ruler number colour + // The ruler is pinned to an embedded monospace so it stays aligned regardless + // of the document's label font (which field labels inherit). Embedded = present + // on every Typst install; override only with a font you know consumers have. + bit-font: "DejaVu Sans Mono", + // A field whose label is wider than its box (by more than this padding) gets + // an exploded callout instead of an inline label. + label-pad: 0.12cm, // inner breathing room when testing if a label fits its box + callout-drop: 0.55cm, // headroom from the row down to the first callout label + callout-bottom: 0.35cm, // space below the last callout label before the next row + callout-spacing: 1.7cm, // horizontal spacing between callout labels (gap mode) + callout-gap: 2.3cm, // enlarged inter-row gap below a row that has callouts + callout-line-height: 0.5cm, // vertical pitch of stacked callout labels (left mode) + callout-stub: 0.16cm, // left mode: short straight stub for a field over its own label + callout-side-gap: 0.35cm, // gap between the fields and a label column (each side) + callout-label-pad: 0.08cm, // gap between a leader's end and its label text + callout-gap-drop: 0.45cm, // gap mode: label row offset up from the band bottom + callout-gap-lane-top: 0.22cm, // gap mode: topmost leader lane below the row + callout-gap-lane-bot: 0.28cm, // gap mode: bottom leader lane above the labels + callout-gap-leader: 0.18cm, // gap mode: leader end just above the label + leader-stroke: 0.4pt + luma(50%), // the leader line + // struct (vertical memory-map) view + struct-width: 4cm, // width of a struct field box + struct-byte-height: 0.3cm, // box height per byte, before clamping + struct-min-height: 0.55cm, // floor so small fields stay legible + struct-max-height: 2.2cm, // ceiling so a huge field can't run off the page + struct-offset-gap: 0.25cm, // gap between the offset/size columns and the box + struct-break-amp: 0.1cm, // break-mark zigzag amplitude (clamped/oversized field) + struct-break-pitch: 0.26cm, // break-mark zigzag tooth width + // hexdump (annotated byte dump) view + hexdump-font: "DejaVu Sans Mono", // grid is monospace, pinned like the bit ruler + hexdump-size: 10pt, // hex/ASCII grid text (mono) + hexdump-legend-size: 9pt, // legend text (mono range + name), smaller than the grid + hexdump-line: 0.5cm, // vertical pitch between dump rows + hexdump-text-color: black, // hex + ASCII glyphs + hexdump-offset-color: luma(45%), // the dimmer left offset column + // Field highlights are opt-in: a hexdump colours only the annotations the + // author gives a `fill:` (e.g. `palette.orange`); there's no auto-cycle. + hexdump-legend-gap: 0.5cm, // gap from the dump down to the legend + hexdump-swatch: 0.3cm, // legend colour-swatch size + hexdump-legend-rows: 3, // target entries per column (drives the 1->2->3 switch) + hexdump-legend-cols: 3, // hard cap on legend columns + hexdump-legend-col-gap: 0.7cm, // horizontal gap between legend columns +) diff --git a/packages/preview/pivot/0.1.0/typst.toml b/packages/preview/pivot/0.1.0/typst.toml new file mode 100644 index 0000000000..9cfec78f5d --- /dev/null +++ b/packages/preview/pivot/0.1.0/typst.toml @@ -0,0 +1,28 @@ +[package] +name = "pivot" +version = "0.1.0" +entrypoint = "lib.typ" +authors = ["cybermallard "] +license = "Apache-2.0" +description = "Draw diagrams for Cyber Threat Intelligence (CTI) analysis." + +repository = "https://github.com/cybermallard/typst-pivot" + +keywords = ["cti", "threat-intelligence", "cybersecurity", "security", "protocol", "packet", "struct", "hexdump"] +categories = ["visualization"] + +# Minimum Typst version. CeTZ 0.5.2 requires >= 0.14.0, and the test runner +# tytanic 0.3.4 embeds Typst 0.14.2 (it compiles the visual-regression +# references), so the toolchain is pinned to 0.14.x. Floor is 0.14.0; CI pins +# Typst 0.14.2 to match tytanic. (Revisit when a tytanic on Typst 0.15 ships.) +compiler = "0.14.0" + +# Keep the published bundle lean. Never exclude README/LICENSE/NOTICE. +# manual.pdf is a GitHub release asset, not bundle weight. +exclude = [ + "tests", + "examples", + "docs", + ".github", + "scripts", +]