diff --git a/packages/preview/xmlit/0.1.3/LICENSE b/packages/preview/xmlit/0.1.3/LICENSE new file mode 100644 index 0000000000..a7179dc406 --- /dev/null +++ b/packages/preview/xmlit/0.1.3/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jason Siefken + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/xmlit/0.1.3/README.md b/packages/preview/xmlit/0.1.3/README.md new file mode 100644 index 0000000000..535fcbc506 --- /dev/null +++ b/packages/preview/xmlit/0.1.3/README.md @@ -0,0 +1,270 @@ +# typst-xmlit + +Tools for generating, validating, and outputting XML using Typst syntax. + +

+ A Typst-rendered page: element constructors derived from a RELAX NG grammar, a valid document serialized to XML, a validation error with a located source snippet, and the same errors highlighted in place +

+ +The page above is [examples/create-from-relaxng.typ](https://github.com/siefkenj/typst-xmlit/blob/0.1.3/examples/create-from-relaxng.typ) +rendered by Typst: it derives typechecked element constructors from a RELAX NG +grammar, then validates and prints a document — pointing right at the mistakes +in an invalid one. + +## Authoring XML + +Create functions which return XML elements with `make-tag`/`make-tags`. +Use the resulting functions using standard typst syntax. Named arguments are converted to attributes and positional arguments +are treated as children. + +### `make-tag(tag, handlers: auto) -> function` + +Creates a tag function for a single element named `tag`. `handlers` overrides +how body content is converted to XML (see [Markup in bodies](#markup-in-bodies)). +The returned tag function takes named arguments as attributes and positional +arguments as children: `(..args) -> content`. + +### `make-tags(..names, handlers: auto) -> array` + +Creates one tag function per name, returned in order for destructuring; +`handlers` is forwarded to each. + +```typst +#import "@preview/xmlit:0.1.3": make-tags, xml-to-string + +#{ + let (foo, bar) = make-tags("foo", "bar") + + // Typst native XML parsing + let xml1 = xml(bytes(`text`.text)) + + // Construct XML by passing using function arguments + let xml2 = foo(bar(baz: "zz"), "text") + + // Construct XML by passing using a code block + let xml3 = foo({ + bar(baz: "zz") + "text" + }) + + // Construct XML by passing content + let xml4 = foo[#bar(baz: "zz")text] + + [ + // All versions render as `text` + #xml-to-string(xml1) + + #xml-to-string(xml2) + + #xml-to-string(xml3) + + #xml-to-string(xml4) + ] +} +``` + +### Markup in bodies + +Inside content (`[...]` blocks) markup is automatically converted into tags: + + - `*bold*` → `bold` + - `_emph_` → `emph` + - `` `code` `` → `code` + - `$x^2$` → `x^2` + - `$ ... $` → `` + +This mapping can be overwritten by providing `handlers` to the make-tag function. + +```typst +#import "../src/lib.typ": * + +#let p = make-tag("p", handlers: ( + // strong -> instead of + "strong": (c, convert, ctx) => ((tag: "alert", attrs: (:), children: convert(c.body)),), + // Control how the _content_ of math is serialized. + "math": (body, convert, ctx) => ("MATH: \"" + repr(body) + "\"",), + // Control what tag is used for math blocks. + "equation": (c, convert, ctx) => { + let f = c.fields() + let tag = if f.block { "md" } else { "m" } + // Use the math handler we defined already. + let math-handler = ctx.handlers.at("math") + ((tag: tag, attrs: (:), children: math-handler(f.body, convert, ctx)),) + }, +)) + +// Becomes: `

A very important point about MATH: "attach(base: [x], t: [2])". It can sometimes be solved with MATH: "root(radicand: [⋅])"

` +#xml-to-string(p[ + A *very important* point about $x^2$. It can sometimes be solved with + $ + sqrt(dot) + $ +]) +``` + +A handler has the signature `handler(element, convert, ctx)` + - `convert` turns any child value (e.g. the element's body) into an array of XML nodes using the +same handler table + - `ctx` provides an object that can be used to look up other handlers that are defined. They will be defined on `ctx.handlers`. + +Unmapped markup (e.g. headings) raises an error naming the element and the +`handlers:` entry that would map it. + +#### Preserving Math + +There is no way (in typst 0.15) to serialize all math such that `eval` +can evaluate it as valid typst code. Since you may want access to the math (for example, +measure it), the `extract-math: true` option may be passed to `xml-to-string`. +If passed, it returns a dictionary `(xml, math-items)`: all found math is +collected into `math-items` and the math in the XML string is replaced with a +sentinel. + +```typst +#let (xml, math-items) = xml-to-string(doc, extract-math: true) +// xml: "

Area: ⟦math-0⟧

" (text sentinels, ⟦id⟧) +// math-items: ("math-0": $pi r^2$, ...) (real equation content) + +// rendered sizes, keyed by the same ids that appear in the XML: +#context math-items.pairs().map(((id, eq)) => (id, measure(eq))) +``` + +Ids are assigned in document order ("math-0", "math-1", ...), so they are +deterministic across compiles. The default (no `extract-math`). + +See [examples/render-xml-with-math.typ](https://github.com/siefkenj/typst-xmlit/blob/0.1.3/examples/render-xml-with-math.typ) for an example where +xml is produced with math rendered as "math" via typst. + +## Typechecked authoring from a RELAX NG grammar + +### `create-from-relaxng(rnc, handlers: auto, wasm: auto) -> dictionary` + +`create-from-relaxng` derives tag functions from a RELAX NG grammar (compact +syntax, `.rnc`) and validates the composed document via a bundled WASM +plugin (see [plugin/](https://github.com/siefkenj/typst-xmlit/blob/0.1.3/plugin/README.md)). It returns +`(elements: dictionary, utils: dictionary)`. Parameters: + +- `rnc` — the grammar source (str/bytes), or a `(file-name: contents)` + dictionary for multi-file grammars (the first entry is the entry point). +- `handlers` — forwarded to every generated tag function. +- `wasm` — the validator plugin; defaults to the bundled one. + +```typst +#import "@preview/xmlit:0.1.3": create-from-relaxng + +#let (utils, elements) = create-from-relaxng( + "start = element foo { element bar { attribute baz { text } }* }", +) +#let (foo, bar) = elements + +#show: utils.validate-and-render + +#foo[#bar(baz: "xx")] +``` + +The returned dictionary destructures into two entries: + +- `elements` — a dictionary mapping each element name defined in the grammar + to its tag function (destructure the ones you need, as above). +- `utils` — grammar-level helpers, each accepting `pretty-print: false` where + applicable (see [Pretty printing](#pretty-printing)): + - `render(body, pretty-print: false) -> content` — a template + (`#show: utils.render`) that serializes its body and renders the XML + source *without validating*. Useful for fast iteration; switch to + `validate-and-render` once the document is ready to be checked. + - `validate-and-render(body, pretty-print: false) -> content` — a template + (`#show: utils.validate-and-render`) that serializes its body, validates + it against the grammar, and renders the XML source. Invalid documents fail + compilation with a readable panic that includes a small line-numbered + snippet of the source around each error, not the whole document: + + ```text + XML failed RELAX NG validation: + - element is not allowed here. Expected element(s): bar. + 1 | + > 2 | + ^^^^^^^ + 3 | + ``` + + No `(line, column)` is shown — those would be positions in the invisible, + internally-generated compact XML string, not anything actually written. + + The snippet is windowed both by line (a couple of lines of context) and, + within the target line itself, by character count — pretty-printing only + breaks lines between all-element children, so a `

` full of prose (or + even a whole document with no element-only nesting) can pretty-print to + one very long line; the character-level window is what keeps the snippet + short regardless. + + Use `.with(pretty-print: true)` in a show rule: + `#show: utils.validate-and-render.with(pretty-print: true)`. + + - `render-and-show-validation-errors(body, pretty-print: true) -> content` — + a template (`#show: utils.render-and-show-validation-errors`) for + authoring/preview: like `validate-and-render`, but instead of panicking on + an invalid document it renders the XML source anyway, highlighting each + offending element's line in place with its error message. Errors that + can't be tied to a specific element are listed below the block. Useful + while iterating on a document you know isn't finished yet. + - `validate(doc) -> (valid: bool, errors: array)` — validate content or an + XML string without panicking. On failure, each entry in `errors` also + carries a `snippet` — the same windowed source excerpt used in + `validate-and-render`'s panic message (`none` only for errors with no + locatable position, e.g. an internal buffer-limit error). For a raw `doc` + string the snippet windows directly around the error's position in that + string (no round-trip through the grammar needed), and the plugin's raw + `line`/`column` fields are kept, since they index the exact string + written. For authored content the snippet is mapped through the element + that produced it, and `line`/`column` are *removed* — those would be + positions in an invisible, internally-generated XML string, not anything + actually written, so they'd only mislead. + - `roots` — the element names allowed as the document root. (All element + names are `elements.keys()`.) + +A `handlers:` argument passed to `create-from-relaxng` is forwarded to every +generated tag function, so one handler table configures markup/math +conversion for the whole grammar (see [Markup in bodies](#markup-in-bodies)). + +## Serializing + +### `xml-to-string(node, handlers: auto, extract-math: false, pretty-print: false) -> str | dictionary` + +`xml-to-string` accepts authored trees (the return value of a tag function or +any markup content), plain node dictionaries/strings/arrays, and — faithfully — +the output of Typst's built-in `xml()` reader. Parameters: + +- `node` — an authored tree, a node dict/str/array, or `xml()` reader output. +- `handlers` — overrides for content conversion (see [Markup in bodies](#markup-in-bodies)). +- `extract-math` — return a `(xml, math-items)` dictionary with equations pulled out (see below). +- `pretty-print` — indent element-only content (see below). + +```typst +#xml-to-string(xml("doc.xml")) +``` + +Attribute order is preserved, text/attribute contexts are escaped correctly, +empty elements self-close, and default-namespace declarations are re-emitted +only where the namespace actually changes. + +### Pretty printing + +Pass `pretty-print: true` to indent the output. Only elements whose children +are *all elements* are reflowed — one child per line; elements containing any +text (mixed content) stay inline, so no significant whitespace is introduced: + +```typst +#xml-to-string(root(a(b()), b(id: "2")), pretty-print: true) +// +// +// +// +// +// + +#xml-to-string(p[Some *bold* text], pretty-print: true) +//

Some bold text

(mixed content — left inline) +``` + +Pretty-printed output is meant for reading; it is not byte-faithful to +`xml()` reader input. + diff --git a/packages/preview/xmlit/0.1.3/src/elem/elem.typ b/packages/preview/xmlit/0.1.3/src/elem/elem.typ new file mode 100644 index 0000000000..b4495545bb --- /dev/null +++ b/packages/preview/xmlit/0.1.3/src/elem/elem.typ @@ -0,0 +1,291 @@ +// elem: author XML trees with Typst syntax. +// +// Tag functions created by `make-tag` return their node wrapped in +// `metadata(...)` content. Because content joins cleanly with content and +// strings, all three call styles work and can be mixed: +// +// #let (foo, bar) = make-tags("foo", "bar") +// #foo(bar(baz: "zz"), "text") // positional +// #foo({ bar(baz: "zz"); "text" }) // code block (content joins) +// #foo[#bar(baz: "zz") text $x^2$] // markup block +// +// The node model is the same one Typst's `xml()` reader uses: an element is a +// dictionary with `tag`, `attrs`, and `children`; a text node is a string. + +// Math constructs whose AST cannot be reconstructed node-by-node, matched +// wholesale by comparing against the parsed form of their source. `dif`, for +// example, expands to `sequence(h(..), class(.., styled(..)))`, none of which +// can be re-created by evaluating serialized output -- but the whole node +// compares equal to `$dif$.body`, so it serializes back to "dif". +#let _special-math-forms = ( + ("dif", repr($dif$.body)), +) + +/// Serialize equation (body) content to a Typst math source string: +/// $x^2$ -> "x^2", $1/2$ -> "1/2", $sqrt(x+1)$ -> "sqrt(x+1)". Symbols +/// appear as their Unicode characters (e.g. "∫" for `integral`). +/// +/// For supported constructs the output round-trips: evaluating it as math +/// (e.g. `eval("$" + s + "$")`) reproduces exactly the original expression +/// (the property is enforced by the unit tests). Constructs without a +/// faithful serialization (matrices, cases, alignment points, ...) fall back +/// to their `repr`, which is visible in the output but is NOT valid math +/// source -- override the `"math"` handler to serialize those yourself. +#let math-to-string(c) = { + if type(c) == str { return c } + if type(c) != content { return repr(c) } + for (source, form) in _special-math-forms { + if repr(c) == form { return source } + } + let join-all(parts) = { + let s = parts.join("") + if s == none { "" } else { s } + } + // Parenthesize a sub-expression unless it is atomic: a single grapheme + // (letters and symbols), a number (single tokens in Typst math), a quoted + // string, or already parenthesized. + let group(s) = { + if s.clusters().len() <= 1 { return s } + if s.match(regex("^[0-9]+(\\.[0-9]+)?$")) != none { return s } + if s.starts-with("\"") and s.ends-with("\"") { return s } + if s.starts-with("(") and s.ends-with(")") { return s } + "(" + s + ")" + } + let name = repr(c.func()) + // Letters and symbols (x, π, ∫) are `symbol` elements: single characters + // that re-evaluate to themselves. + if name == "symbol" { + c.text + } else if name == "text" { + // Digit runs / decimals and single characters (operators like "+", ",") + // re-evaluate to the same `text` node; longer runs would re-parse as + // per-letter symbols, so they are emitted as quoted strings. + let t = c.text + if t.clusters().len() <= 1 or t.match(regex("^[0-9]+(\\.[0-9]+)?$")) != none { + t + } else { + "\"" + t.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + } + } else if name == "space" { + " " + } else if name == "sequence" { + join-all(c.children.map(math-to-string)) + } else if name == "equation" { + math-to-string(c.fields().body) + } else if name == "lr" { + // Parentheses re-create the lr group when re-parsed; other delimiter + // pairs (|x|, [x], ...) need the explicit lr(...) form. + let inner = math-to-string(c.fields().body) + if inner.starts-with("(") and inner.ends-with(")") { + inner + } else { + "lr(" + inner + ")" + } + } else if name == "op" { + // Named operators (lim, sin, max, ...) re-parse to the identical op node. + c.fields().text.text + } else if name == "primes" { + join-all(range(c.fields().count).map(_ => "'")) + } else if name == "frac" { + let f = c.fields() + group(math-to-string(f.num)) + "/" + group(math-to-string(f.denom)) + } else if name == "root" { + let f = c.fields() + let index = f.at("index", default: none) + if index == none { + "sqrt(" + math-to-string(f.radicand) + ")" + } else { + "root(" + math-to-string(index) + ", " + math-to-string(f.radicand) + ")" + } + } else if name == "attach" { + let f = c.fields() + let s = math-to-string(f.base) + // Primes attach top-right without an operator: $x'$ -> "x'". + let tr = f.at("tr", default: none) + if tr != none { s += math-to-string(tr) } + let b = f.at("b", default: none) + if b != none { s += "_" + group(math-to-string(b)) } + let t = f.at("t", default: none) + if t != none { s += "^" + group(math-to-string(t)) } + s + } else { + // No faithful serialization known: degrade to repr (visible, but not + // valid math source). Override the "math" handler for full control. + repr(c) + } +} + +/// Built-in handlers that map Typst content elements to XML nodes. +/// +/// Keys are content-function names (as produced by `repr(c.func())`), plus the +/// special `"math"` slot used to serialize equation bodies. Each handler is +/// called as `handler(element, convert, ctx)` where: +/// +/// - `convert`: function converting any child value to an array of nodes +/// (using the same merged handler table); use it to process bodies: +/// "strong": (c, convert, ctx) => ((tag: "b", attrs: (:), children: convert(c.body)),) +/// - `ctx`: a dictionary for handlers that delegate to another slot, with: +/// - `handlers`: the merged handler table (built-ins + user overrides); +/// see the built-in `equation` handler, which dispatches to `"math"` +/// +/// and must return either an array of nodes (element dictionaries and/or +/// strings) or a single such node (a bare element dictionary or string), which +/// is normalized to a one-element array. Override or extend any of these by +/// passing `handlers: (...)` to `make-tag` or `make-tags`. +#let default-handlers = ( + "sequence": (c, convert, ctx) => c.children.map(convert).flatten(), + "text": (c, convert, ctx) => (c.text,), + "space": (c, convert, ctx) => (" ",), + "linebreak": (c, convert, ctx) => ("\n",), + "parbreak": (c, convert, ctx) => ("\n\n",), + "smartquote": (c, convert, ctx) => { + let double = c.fields().at("double", default: true) + (if double { "\"" } else { "'" },) + }, + // A show/set rule inside a body wraps the rest in `styled`; recurse into the + // wrapped child (the style information itself has no XML representation). + "styled": (c, convert, ctx) => convert(c.fields().child), + // Nodes authored by xmlit tag functions travel through markup as metadata. + "metadata": (c, convert, ctx) => { + let v = c.value + if type(v) == dictionary and "tag" in v { + (v,) + } else { + panic("xmlit: metadata content does not carry an XML node: " + repr(v)) + } + }, + // Default markup mappings (in the spirit of Typst's HTML export). Override + // via `handlers:` to emit different tags. + "strong": (c, convert, ctx) => ((tag: "b", attrs: (:), children: convert(c.body)),), + "emph": (c, convert, ctx) => ((tag: "em", attrs: (:), children: convert(c.body)),), + "raw": (c, convert, ctx) => { + let f = c.fields() + // A plain code value (e.g. a bare dictionary) interpolated into markup + // displays as inline raw with lang "typc" -- almost certainly a mistake. + if f.at("lang", default: none) == "typc" { + panic( + "xmlit: a plain code value was interpolated into markup: `" + f.text + + "`. Tag functions already return content; did you interpolate a " + + "bare dictionary?", + ) + } + let tag = if f.at("block", default: false) { "pre" } else { "c" } + ((tag: tag, attrs: (:), children: (f.text,)),) + }, + // $...$ -> , $ ... $ -> . The children (string form) are produced + // by the "math" slot; the node additionally keeps the actual equation + // content under the reserved `math` key, so `xml-to-string(..., + // extract-math: true)` can hand it back for rendering/measuring. + "equation": (c, convert, ctx) => { + let f = c.fields() + let tag = if f.block { "md" } else { "m" } + let math-handler = ctx.handlers.at("math") + ((tag: tag, attrs: (:), children: math-handler(f.body, convert, ctx), math: c),) + }, + // Special slot (not a content-function name): serializes the *body* of an + // equation into child nodes. Unlike the others, it receives the equation's + // BODY content, not the equation element. The default emits a best-effort + // Typst-flavored linear string (see `math-to-string`); replace it via + // `handlers: ("math": (body, convert, ctx) => (...))`. + "math": (body, convert, ctx) => (math-to-string(body),), +) + +/// Convert any child value into an array of XML nodes. +/// +/// Accepts: strings (text nodes), element dictionaries, arrays of either, +/// numbers/booleans (stringified), `none` (dropped), and content (walked +/// using the handler table -- see `default-handlers`). +#let convert(value, handlers: auto) = { + if type(value) == str { return (value,) } + if value == none { return () } + if type(value) == int or type(value) == float { + return (str(value),) + } + if type(value) == bool { + return (repr(value),) + } + if type(value) == array { + return value.map(v => convert(v, handlers: handlers)).flatten() + } + if type(value) == dictionary { + if "tag" in value { return (value,) } + panic("xmlit: expected an element dictionary with a `tag` key, found: " + repr(value)) + } + if type(value) == content { + let merged = if handlers == auto { + default-handlers + } else { + default-handlers + handlers + } + let name = repr(value.func()) + let handler = merged.at(name, default: none) + if handler == none { + panic( + "xmlit: no handler for content element `" + name + "`. " + + "Pass `handlers: (\"" + name + "\": (elem, convert, ctx) => ..)` to " + + "make-tag to map it to XML.", + ) + } + let convert-child = v => convert(v, handlers: handlers) + let ctx = (handlers: merged) + let result = handler(value, convert-child, ctx) + // A handler may return a single node (a bare element dictionary or a + // string) instead of a one-element array; normalize any non-array return + // back through `convert` (arrays take the fast path unchanged). + return if type(result) == array { result } else { convert(result, handlers: handlers) } + } + panic("xmlit: cannot convert value of type " + repr(type(value)) + " to XML: " + repr(value)) +} + +/// Convert a content block to an array of XML nodes (exported alias of +/// `convert` for content input). +#let content-to-children(c, handlers: auto) = convert(c, handlers: handlers) + +/// True for text nodes that consist entirely of whitespace. +#let is-whitespace(v) = type(v) == str and v.trim() == "" + +/// Drop whitespace-only text nodes from the edges of a children array. +/// Applied to content-block bodies so that `#foo[ text ]` == `#foo("text")`. +#let trim-ws(children) = { + let out = children + while out.len() > 0 and is-whitespace(out.first()) { out = out.slice(1) } + while out.len() > 0 and is-whitespace(out.last()) { out = out.slice(0, out.len() - 1) } + out +} + +/// Create a function that produces an XML element with the given tag name. +/// Named arguments become XML attributes; positional arguments become +/// children (strings, other tag calls, arrays, or arbitrary content -- +/// including trailing `[...]` bodies). +/// +/// The returned function yields the node wrapped in `metadata(...)` content, +/// so tag calls can be freely embedded in markup and joined in code blocks. +/// +/// `handlers` extends/overrides `default-handlers` for content conversion. +/// +/// Example: +/// #let foo = make-tag("foo") +/// #let bar = make-tag("bar") +/// #foo[#bar(baz: "zz") some text] +/// // => some text +#let make-tag(tag, handlers: auto) = (..args) => { + let children = args.pos().map(a => { + let nodes = convert(a, handlers: handlers) + // Trim insignificant edge whitespace of `[...]` bodies, but keep + // explicitly passed strings verbatim. + if type(a) == content { trim-ws(nodes) } else { nodes } + }).flatten() + metadata((tag: tag, attrs: args.named(), children: children)) +} + +/// Generic one-off element constructor (compare `html.elem`): +/// #elem("foo", elem("bar", baz: "zz")) +#let elem(tag, ..args) = make-tag(tag)(..args) + +/// Create several tag functions at once (destructurable): +/// #let (foo, bar) = make-tags("foo", "bar") +/// A `handlers:` named argument is forwarded to every created tag. +#let make-tags(..names) = { + let handlers = names.named().at("handlers", default: auto) + names.pos().map(n => make-tag(n, handlers: handlers)) +} diff --git a/packages/preview/xmlit/0.1.3/src/lib.typ b/packages/preview/xmlit/0.1.3/src/lib.typ new file mode 100644 index 0000000000..dae6826306 --- /dev/null +++ b/packages/preview/xmlit/0.1.3/src/lib.typ @@ -0,0 +1,27 @@ +// xmlit: Generate XML documents using Typst syntax. +// +// This file only re-exports the package's public API. Implementations live in +// per-feature modules: +// +// * elem/elem.typ — author XML trees with Typst syntax: +// tag functions (`make-tag`, `make-tags`, +// `elem`) usable positionally, in code +// blocks, and in markup bodies; the +// content walker (`content-to-children`, +// `convert`) with its configurable +// `default-handlers` table. +// * xml-to-string/xml-to-string.typ — serialize node trees to an XML string: +// authored trees as well as faithful +// re-serialization of the output of +// Typst's built-in `xml()` reader. +// * xml-to-string/make-tag.typ — `to-xml`, a minimal serializer for +// plain node dictionaries. +// * relaxng/relaxng.typ — `create-from-relaxng`: derive tag +// functions from a RELAX NG grammar +// (compact syntax) and validate the +// composed document via a WASM plugin. + +#import "elem/elem.typ": default-handlers, convert, content-to-children, make-tag, make-tags, elem, math-to-string +#import "relaxng/relaxng.typ": create-from-relaxng +#import "xml-to-string/xml-to-string.typ": esc-text, esc-attr, xml-to-string, xml-to-string-with-ranges +#import "xml-to-string/make-tag.typ": xml-escape, to-xml diff --git a/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.typ b/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.typ new file mode 100644 index 0000000000..47e770e610 --- /dev/null +++ b/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.typ @@ -0,0 +1,510 @@ +// relaxng: create typechecked XML-authoring functions from a RELAX NG grammar. +// +// `create-from-relaxng` compiles a grammar in RELAX NG *compact syntax* +// (`.rnc`) via a WASM plugin and returns a dictionary with two entries: +// +// - `elements`: a dictionary mapping each element name defined in the +// grammar to its tag function. +// - `utils`: grammar-level helpers: +// - `render`: a template that serializes its body and renders the XML +// source, without validating. Usable as `#show: utils.render`. +// - `validate-and-render`: a template that serializes its body, +// validates it against the grammar (panicking with readable errors if +// invalid), and renders the XML source. Usable as +// `#show: utils.validate-and-render`. +// - `render-and-show-validation-errors`: like `validate-and-render`, but +// instead of panicking it renders the XML source and highlights the +// offending element(s) in place with each error message. Usable as +// `#show: utils.render-and-show-validation-errors`. +// - `validate`: validate content or an XML string; returns the raw +// result `(valid: bool, errors: (..))` instead of panicking. +// - `roots`: the element names allowed as the document root. +// +// Example: +// #import "@preview/xmlit:0.1.0": create-from-relaxng +// #let (utils, elements) = create-from-relaxng( +// "start = element foo { element bar { attribute baz { text } }* }", +// ) +// #let (foo, bar) = elements +// #show: utils.validate-and-render +// #foo[#bar(baz: "xx")] + +#import "../elem/elem.typ": make-tag +#import "../xml-to-string/xml-to-string.typ": xml-to-string, xml-to-string-with-ranges + +/// The bundled RELAX NG plugin (see plugin/typst-relaxng in the repository). +#let relaxng-plugin = plugin("relaxng.wasm") + +/// Given `ranges` from `xml-to-string-with-ranges` and a byte `offset` (a +/// validator error position), return the `path` of the deepest element whose +/// `[start, end)` contains the offset, or `none` if none does. "Deepest" = the +/// tightest containing range, i.e. the most specific element. +#let locate-path(ranges, offset) = { + let best = none + for r in ranges { + if r.start <= offset and offset < r.end { + if best == none or (r.end - r.start) < (best.end - best.start) { best = r } + } + } + if best == none { none } else { best.path } +} + +/// 1-based line number of byte `offset` within `text`. +#let line-of(text, offset) = text.slice(0, offset).split("\n").len() + +// A wavy ("squiggle") underline of the given width, for marking an error span +// the way an editor does. +#let _squiggle(width, color: rgb("#cc0000")) = { + let period = 2.4pt + let amp = 1.1pt + let n = calc.max(2, int(width / period)) + let step = width / n + let verts = range(n + 1).map(i => (i * step, if calc.even(i) { 0pt } else { amp })) + box(width: width, height: amp, curve( + stroke: 0.6pt + color, + curve.move(verts.first()), + ..verts.slice(1).map(v => curve.line(v)), + )) +} + +// Draw `body` with a squiggle underline beneath it. The squiggle is anchored +// to `body`'s own box, so it stays under the right glyphs even as the +// surrounding line wraps. +#let _squiggle-under(body) = box({ + body + context place(bottom + left, dy: 2pt, _squiggle(measure(body).width)) +}) + +// Rebuild a source line, wrapping each error span (a byte `(col, span)` within +// the line, clipped to the line's end) in a squiggle underline while leaving +// the rest as ordinary syntax-highlighted XML. `spans` may be empty. +#let _underline-spans(line-text, spans) = { + let sorted = spans.sorted(key: s => s.col) + let pos = 0 + let out = () + for s in sorted { + let a = calc.max(pos, s.col) + let b = calc.min(line-text.len(), s.col + s.span) + if a > pos { out.push(raw(line-text.slice(pos, a), lang: "xml")) } + if b > a { + out.push(_squiggle-under(raw(line-text.slice(a, b), lang: "xml"))) + pos = b + } + } + if pos < line-text.len() { out.push(raw(line-text.slice(pos), lang: "xml")) } + out.join() +} + +// Tunables for the snippet shown under a located error. Character counts +// (not bytes) -- see the char-index conversion inside `snippet-at`. +#let _snippet-context-lines = 2 +#let _snippet-max-line-chars = 120 +#let _snippet-window-chars = 60 +#let _snippet-max-marked-chars = 60 +#let _snippet-max-errors = 5 + +// `array.join` returns `none` (not "") for an empty array; normalize so the +// windowing code below always gets a string back from a cluster slice. +#let _joined(cls) = { + let s = cls.join("") + if s == none { "" } else { s } +} + +// Convert a byte offset within `text` to a character (grapheme cluster) +// index by walking clusters and summing byte lengths -- keeps all +// subsequent windowing/padding in character units (safe for multi-byte +// content and correct for monospace alignment), even though the plugin's +// positions and the ranges machinery are byte offsets throughout. +#let _char-index-of(text, byte-offset) = { + let pos = 0 + let idx = 0 + for c in text.clusters() { + if pos >= byte-offset { break } + pos += c.len() + idx += 1 + } + idx +} + +// Cap a non-target context line to a bounded number of characters from its +// start (no error position on it to center a window around). +#let _capped-line(text) = { + let cls = text.clusters() + if cls.len() <= _snippet-max-line-chars { + text + } else { + cls.slice(0, _snippet-max-line-chars).join("") + "…" + } +} + +// Build the target line's displayed (possibly windowed) text plus a +// caret-underline beneath it, both bounded in character count regardless of +// the line's real length. The marked span itself is windowed too (head "…" +// tail): a long invalid element -- e.g. a mixed-content

full of prose -- +// would otherwise reproduce, carets and all, the very wall of text this +// machinery exists to avoid. +#let _target-line-snippet(text, start-idx, end-idx) = { + let cls = text.clusters() + let needs-window = cls.len() > _snippet-max-line-chars + let lo = if needs-window { calc.max(0, start-idx - _snippet-window-chars) } else { 0 } + let hi = if needs-window { calc.min(cls.len(), end-idx + _snippet-window-chars) } else { cls.len() } + let before = (if needs-window and lo > 0 { "…" } else { "" }) + _joined(cls.slice(lo, start-idx)) + let marked = _joined(cls.slice(start-idx, end-idx)) + let mcls = marked.clusters() + if mcls.len() > _snippet-max-marked-chars { + let half = calc.quo(_snippet-max-marked-chars, 2) + marked = _joined(mcls.slice(0, half)) + "…" + _joined(mcls.slice(mcls.len() - half)) + } + let after = _joined(cls.slice(end-idx, hi)) + (if needs-window and hi < cls.len() { "…" } else { "" }) + ( + text: before + marked + after, + pad: " " * before.clusters().len(), + carets: "^" * calc.max(marked.clusters().len(), 1), + ) +} + +/// Build a small line-numbered, character-windowed excerpt of `text` around +/// the byte range `[start, end)` (see `format-errors` for the two-level, +/// bounded-size windowing rationale), with the target line's span +/// underlined. `start`/`end` must be valid byte positions within `text`. +/// +/// This is the shared core `error-snippet` builds on top of (after mapping +/// a validator offset to an authored element's position in the pretty +/// string). It needs no element-path structure itself, so it also works +/// directly on a raw XML string passed to `validate` -- there `start`/`end` +/// are already byte offsets into that exact string (it IS both what was +/// validated and what to excerpt), no path-mapping step required. +#let snippet-at(text, start, end) = { + let lines = text.split("\n") + let target = line-of(text, start) + let col = text.slice(0, start).split("\n").last().len() + let target-text = lines.at(target - 1) + let span = calc.min(end - start, target-text.len() - col) + + let tl = _target-line-snippet(target-text, _char-index-of(target-text, col), _char-index-of(target-text, col + span)) + + let lo = calc.max(1, target - _snippet-context-lines) + let hi = calc.min(lines.len(), target + _snippet-context-lines) + let width = str(hi).len() + let pad-num(n) = " " * (width - str(n).len()) + str(n) + + range(lo, hi + 1).map(n => { + let gutter = if n == target { "> " } else { " " } + let prefix = gutter + pad-num(n) + " | " + if n == target { + let caret-line = " " * (width + 5) + tl.pad + tl.carets + prefix + tl.text + "\n" + caret-line + } else { + prefix + _capped-line(lines.at(n - 1)) + } + }).join("\n") +} + +/// Locate a single error's byte offset -> deepest element -> that element's +/// line in the pretty-printed source, and render a small line-numbered +/// window around it via `snippet-at`. Returns `none` if the error has no +/// position or can't be mapped to an element (e.g. `TextBufferOverflow`, or +/// `doc` was a raw XML string rather than authored content, which carries +/// no element path structure -- use `snippet-at` on that string directly +/// instead, no mapping needed). +/// +/// `compact-ranges`/`pretty` are as in `format-errors`. `e` is a single +/// entry from a `(valid: false, errors: (..))` validation result. +#let error-snippet(compact-ranges, pretty, e) = { + if e.at("start", default: none) == none { return none } + let path = locate-path(compact-ranges, e.start) + if path == none { return none } + let drange = pretty.ranges.find(r => r.path == path) + if drange == none { return none } + snippet-at(pretty.xml, drange.start, drange.end) +} + +/// Format the errors of a `(valid: false, errors: (..))` validation result +/// into a readable multi-line string, with a small windowed snippet of the +/// pretty-printed source around each error's location instead of dumping the +/// whole document. `compact-ranges` is the `ranges` from +/// `xml-to-string-with-ranges` of the COMPACT serialization (the one +/// actually validated; used to locate each error's byte offset via +/// `locate-path`). `pretty` is `(xml: str, ranges: array)` from +/// `xml-to-string-with-ranges(..., pretty-print: true)` of the SAME tree +/// (used to build the displayed snippet). +/// +/// Pretty-printing only breaks lines between all-element children (see +/// `xml-to-string`'s `pretty-print`); mixed content -- e.g. a `

` full of +/// prose -- stays on one line however long, and in the extreme a whole +/// document can pretty-print to a single line with no newlines at all. So +/// the target line itself is ALSO windowed to a bounded number of characters +/// around the error (not just "N lines of context") -- that inner windowing +/// is what actually guarantees a bounded snippet regardless of document +/// shape. +#let format-errors(result, compact-ranges, pretty) = { + // No "(line, column)" suffix here: this is always called on authored + // content (from `validate-and-render`), where those positions are offsets + // into an invisible, internally-generated compact string the user never + // sees or writes -- meaningless without the snippet, which already shows + // the actual location relative to the readable pretty-printed source. + let entries = result.errors.map(e => { + let snippet = error-snippet(compact-ranges, pretty, e) + "- " + e.message + (if snippet == none { "" } else { "\n" + snippet }) + }) + + let shown = entries.slice(0, calc.min(entries.len(), _snippet-max-errors)) + let rest = entries.len() - shown.len() + shown.join("\n\n") + (if rest > 0 { "\n\n...and " + str(rest) + " more error(s)" } else { "" }) +} + +/// Create XML-authoring functions from a RELAX NG grammar (compact syntax). +/// +/// Returns `(elements: (..), utils: (..))` -- destructure it directly: +/// +/// #let (utils, elements) = create-from-relaxng(rnc) +/// +/// - `elements` maps each element name defined in the grammar to its tag +/// function; destructure what you need: `#let (foo, bar) = elements` +/// - `utils` holds the grammar-level helpers `render` (a `#show:` template +/// that serializes its body and renders the XML source WITHOUT validating +/// -- for fast iteration; accepts `pretty-print`), `validate-and-render` +/// (a `#show:` template that validates its body and renders the XML +/// source, panicking with readable errors if invalid; also accepts +/// `pretty-print` -- `#show: utils.validate-and-render.with(pretty-print: +/// true)`), `render-and-show-validation-errors` (a `#show:` template that, +/// instead of panicking, renders the XML source and highlights the offending +/// element(s) with their messages in place -- for authoring/preview; also +/// accepts `pretty-print`, default true), `validate` (non-panicking; returns +/// `(valid: bool, errors: (..))`), and `roots` (the element names allowed as +/// the document root). +/// +/// - `rnc`: the grammar source (str or bytes), or -- for grammars split +/// across several files with `include`/`external` -- a dictionary mapping +/// file names to file contents. The FIRST entry is the entry point: +/// create-from-relaxng(( +/// "pretext.rnc": read("pretext.rnc"), +/// "pf-adapter.rnc": read("pf-adapter.rnc"), +/// ... +/// )) +/// - `handlers`: forwarded to `make-tag` -- overrides for how Typst content +/// (markup, math, ...) is converted to XML. +/// - `wasm`: the validator plugin; defaults to the bundled one. Pass a +/// `plugin(...)` module or raw wasm bytes to substitute your own build. +#let create-from-relaxng(rnc, handlers: auto, wasm: auto) = { + let p = if wasm == auto { + relaxng-plugin + } else if type(wasm) == bytes { + plugin(wasm) + } else { + wasm + } + // Multi-file grammars are sent as a JSON VFS object (the plugin detects + // the leading "{"); single grammars are sent verbatim. + let rnc-bytes = if type(rnc) == dictionary { + bytes(json.encode(rnc)) + } else { + bytes(rnc) + } + let info = json(p.list_elements(rnc-bytes)) + + // Validate content (authored with the tag functions) or a raw XML string. + // On failure, each entry in `errors` additionally carries a `snippet`: the + // same located, windowed source excerpt used in `validate-and-render`'s + // panic message -- for authored content via `error-snippet`, or directly + // via `snippet-at` for a raw string (its own text is both what was + // validated and what to excerpt, so no element-path mapping is needed). + // `none` only if the error itself has no position (e.g. + // `TextBufferOverflow`). Ranges/snippets are only computed on this (rare) + // failure path, so the common valid-input case stays as cheap as before. + let validate = doc => { + let is-str = type(doc) == str + let xml-str = if is-str { doc } else { xml-to-string(doc, handlers: handlers) } + let result = json(p.validate(rnc-bytes, bytes(xml-str))) + if result.valid { + return result + } + if is-str { + // The exact validated string IS the source to excerpt -- window + // snippets directly around each error's byte offset in it, no + // element-path mapping needed (that machinery only exists to bridge + // between separately-serialized compact/pretty representations of + // AUTHORED content; a raw string has just the one representation). + return ( + valid: false, + errors: result.errors.map(e => { + let snippet = if e.at("start", default: none) == none { + none + } else { + snippet-at(xml-str, e.start, e.at("end", default: e.start)) + } + e + (snippet: snippet) + }), + ) + } + let compact = xml-to-string-with-ranges(doc, handlers: handlers) + let pretty = xml-to-string-with-ranges(doc, handlers: handlers, pretty-print: true) + ( + valid: false, + errors: result.errors.map(e => { + // line/column are positions in the invisible, internally-generated + // compact string -- meaningless for authored content (the user + // never wrote that string), so drop them; `snippet` already shows + // the real location relative to the readable pretty-printed source. + let with-snippet = e + (snippet: error-snippet(compact.ranges, pretty, e)) + let _ = with-snippet.remove("line", default: none) + let _ = with-snippet.remove("column", default: none) + with-snippet + }), + ) + } + + // Non-validating template: serialize the body and render the XML source, + // without calling the validator at all. Use as `#show: utils.render`, or + // with options via `#show: utils.render.with(pretty-print: true)`. Useful + // while iterating, when paying for a validation round-trip on every render + // isn't wanted; switch to `validate-and-render` once the document is ready + // to be checked. + let render = (body, pretty-print: false) => { + raw(xml-to-string(body, handlers: handlers, pretty-print: pretty-print), lang: "xml", block: true) + } + + // Validating template: serialize the body, panic on validation errors, + // render the XML source on success. Use as + // `#show: utils.validate-and-render`, or with options via + // `#show: utils.validate-and-render.with(pretty-print: true)`. + // + // Validation always uses the compact serialization (so cosmetic + // pretty-print whitespace can never affect the result); only the rendered + // output is indented when `pretty-print` is true. The panic message uses a + // windowed snippet of the source around each error's location, not the + // whole document (see `format-errors`) -- ranges are only computed on this + // (rare) failure path, so the common valid-input case stays cheap. + let validate-and-render = (body, pretty-print: false) => { + let xml-str = xml-to-string(body, handlers: handlers) + let result = json(p.validate(rnc-bytes, bytes(xml-str))) + if not result.valid { + let compact = xml-to-string-with-ranges(body, handlers: handlers) + let pretty = xml-to-string-with-ranges(body, handlers: handlers, pretty-print: true) + panic( + "XML failed RELAX NG validation:\n" + format-errors(result, compact.ranges, pretty), + ) + } + let display = if pretty-print { + xml-to-string(body, handlers: handlers, pretty-print: true) + } else { + xml-str + } + raw(display, lang: "xml", block: true) + } + + // Non-panicking, error-locating template: serialize and render the body's + // XML source, but when validation fails, highlight the offending element(s) + // in place and show each message next to the element it belongs to (instead + // of aborting the compile like `validate-and-render`). Use as + // `#show: utils.render-and-show-validation-errors`, or with options via + // `.with(pretty-print: false)`. + // + // Validation still runs on the compact serialization (so cosmetic + // pretty-print whitespace can never affect the result); the plugin's + // byte-offset error positions are mapped to the specific element via + // `xml-to-string-with-ranges`, then to that element's line in the rendered + // (pretty by default) output. Ranges are only computed on the failure + // path -- the cheap, ranges-free `xml-to-string` handles the common + // valid-input case (mirrors `validate-and-render` and the plugin's own + // fast-validate/slow-diagnostic split). + let render-and-show-validation-errors = (body, pretty-print: true) => { + let xml-str = xml-to-string(body, handlers: handlers) + let result = json(p.validate(rnc-bytes, bytes(xml-str))) + + if result.valid { + let display-str = if pretty-print { + xml-to-string(body, handlers: handlers, pretty-print: true) + } else { + xml-str + } + return raw(display-str, lang: "xml", block: true) + } + + let compact = xml-to-string-with-ranges(body, handlers: handlers, pretty-print: false) + let display = xml-to-string-with-ranges(body, handlers: handlers, pretty-print: pretty-print) + + // Map each error -> deepest offending element -> that element's line in the + // displayed source. Errors that can't be tied to an element are listed + // separately below the block. + let messages-by-line = (:) + let spans-by-line = (:) + let unlocated = () + for e in result.errors { + // `start` is none (JSON null) for positionless errors (e.g. the + // plugin's internal buffer/pattern limits) -- those can't be tied to + // an element, so they fall through to the `unlocated` list. + let start = e.at("start", default: none) + let path = if start == none { none } else { locate-path(compact.ranges, start) } + let drange = if path == none { none } else { display.ranges.find(r => r.path == path) } + if drange == none { + unlocated.push(e.message) + } else { + let key = str(line-of(display.xml, drange.start)) + messages-by-line.insert(key, messages-by-line.at(key, default: ()) + (e.message,)) + // Byte column of the element's start within its line, plus the + // element's full byte length (clipped to the line by `_underline-spans`). + let col = display.xml.slice(0, drange.start).split("\n").last().len() + spans-by-line.insert(key, spans-by-line.at(key, default: ()) + ((col: col, span: drange.end - drange.start),)) + } + } + + // Render the source as a single-column grid, one row per line. A grid + // (rather than a `raw` block + `show raw.line`) is used because the + // squiggle underlines are themselves `raw`, which would recurse through a + // `raw.line` show rule. `row-gutter: 0` + a per-row `inset` make the red + // fill of consecutive error lines touch (contiguous) while still giving + // every line vertical breathing room. + let arrow = place(top + left, dx: -1em, text(fill: rgb("#cc0000"), size: 0.85em)[←]) + { + grid( + columns: (1fr,), + row-gutter: 0pt, + inset: (x: 4pt, y: 3pt), + fill: (_, row) => if str(row + 1) in messages-by-line { rgb("#ffe3e3") } else { none }, + ..display.xml.split("\n").enumerate().map(((i, line-text)) => { + let key = str(i + 1) + let msgs = messages-by-line.at(key, default: ()) + if msgs.len() > 0 { + // The message lives in a reserved right column so a wrapping + // message stays within its own block (never wrapping back under + // the source); the `←` is `place`d into the gutter so it hangs to + // the left of the message text instead of being part of the flow. + grid( + columns: (1fr, 40%), + column-gutter: 1.4em, + // Source line with the offending span(s) squiggle-underlined. + _underline-spans(line-text, spans-by-line.at(key, default: ())), + { + arrow + text(fill: rgb("#cc0000"), size: 0.85em, style: "italic")[#msgs.join("; ")] + }, + ) + } else { + raw(line-text, lang: "xml") + } + }), + ) + for m in unlocated { + text(fill: rgb("#cc0000"), size: 0.85em)[⚠ #m] + linebreak() + } + } + } + + let tags = (:) + for name in info.elements { + tags.insert(name, make-tag(name, handlers: handlers)) + } + + ( + elements: tags, + utils: ( + render: render, + validate-and-render: validate-and-render, + render-and-show-validation-errors: render-and-show-validation-errors, + validate: validate, + roots: info.roots, + ), + ) +} diff --git a/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.wasm b/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.wasm new file mode 100755 index 0000000000..a2e7e29c80 Binary files /dev/null and b/packages/preview/xmlit/0.1.3/src/relaxng/relaxng.wasm differ diff --git a/packages/preview/xmlit/0.1.3/src/xml-to-string/make-tag.typ b/packages/preview/xmlit/0.1.3/src/xml-to-string/make-tag.typ new file mode 100644 index 0000000000..1ce2f7ec0b --- /dev/null +++ b/packages/preview/xmlit/0.1.3/src/xml-to-string/make-tag.typ @@ -0,0 +1,38 @@ +// to-xml: serialize plain node dictionaries (no namespace handling). The +// element-authoring API (`make-tag`, `elem`, ...) lives in ../elem/elem.typ. + +/// Escape special XML characters in a string value. +#let xml-escape(s) = { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} + +/// Serialize a single XML node (dictionary with `tag`, `attrs`, `children`) +/// or a plain string to an XML string. +#let to-xml(node) = { + if type(node) == str { + return xml-escape(node) + } + + let tag = node.tag + let attrs = node.at("attrs", default: (:)) + let children = node.at("children", default: ()) + + // Build attribute string + let attrs-str = attrs.pairs().map(((k, v)) => { + let vs = if type(v) == str { v } else if type(v) == bool { repr(v) } else { str(v) } + " " + k + "=\"" + xml-escape(vs) + "\"" + }).join("") + + // Serialize children + let inner = children.map(to-xml).join("") + + if inner == "" { + "<" + tag + attrs-str + " />" + } else { + "<" + tag + attrs-str + ">" + inner + "" + } +} diff --git a/packages/preview/xmlit/0.1.3/src/xml-to-string/xml-to-string.typ b/packages/preview/xmlit/0.1.3/src/xml-to-string/xml-to-string.typ new file mode 100644 index 0000000000..36da05eebe --- /dev/null +++ b/packages/preview/xmlit/0.1.3/src/xml-to-string/xml-to-string.typ @@ -0,0 +1,236 @@ +// xml-to-string: serialize XML node trees to an XML string -- both trees +// authored with xmlit tag functions and the output of Typst's built-in +// `xml()` reader (faithfully reversed). + +#import "../elem/elem.typ": convert + +/// Escape a string for use as XML text content (`&`, `<`, `>`). +/// `&` must be replaced first so already-escaped entities are not double-escaped. +#let esc-text(s) = { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} + +/// Escape a string for use inside a double-quoted attribute value +/// (`&`, `<`, `"`). +#let esc-attr(s) = { + s.replace("&", "&") + .replace("\"", """) + .replace("<", "<") +} + +// One level of pretty-print indentation. +#let _indent-unit = " " + +// Core serializer. Returns `(text: str, math: dict, ranges: array)`. When +// `extract` is true, a node carrying the reserved `math` key (an equation +// element, as produced by the built-in `equation` handler) is emitted with a +// text sentinel in place of its serialized children, and the equation content +// is collected into `math` under a sequential document-order id. +// +// When `pretty` is true, an element whose children are all elements (no text +// nodes) has each child placed on its own line, indented by `depth` levels. +// Elements with any text child (mixed content) stay inline, so significant +// whitespace is never introduced. +// +// When `record` is true, `ranges` collects one `(path, start, end)` entry per +// element node, where `path` is the sequence of child indices from the root +// and `start`/`end` are byte offsets of the element's serialized text within +// the returned `text` (byte offsets so they line up with the plugin's error +// positions and Typst's byte-indexed `str.len`/`str.slice`). Otherwise +// `ranges` is empty and the bookkeeping is skipped. +#let _serialize(node, inherited-ns, handlers, extract, pretty, depth, math, path, record) = { + // Authored content (metadata-wrapped nodes, markup, ...): normalize first. + if type(node) == content { + node = convert(node, handlers: handlers) + } + + // Array of nodes (e.g. the direct return of `xml(...)`). + if type(node) == array { + let text = "" + let ranges = () + let off = 0 + let i = 0 + for n in node { + let r = _serialize(n, inherited-ns, handlers, extract, pretty, depth, math, path + (i,), record) + if record { + for rg in r.ranges { ranges.push((path: rg.path, start: rg.start + off, end: rg.end + off)) } + } + text += r.text + off += r.text.len() + math = r.math + i += 1 + } + return (text: text, math: math, ranges: ranges) + } + + if type(node) == str { + return (text: esc-text(node), math: math, ranges: ()) + } + + let tag = node.at("tag", default: "") + // Comment / processing-instruction sentinel: content is lost, so skip it. + if tag == "" { + return (text: "", math: math, ranges: ()) + } + + let ns = node.at("namespace", default: none) + let attrs = node.at("attrs", default: (:)) + let children = node.at("children", default: ()) + + let attrs-str = attrs.pairs().map(((k, v)) => { + let vs = if type(v) == str { v } else if type(v) == bool { repr(v) } else { str(v) } + " " + k + "=\"" + esc-attr(vs) + "\"" + }).join("") + if attrs-str == none { attrs-str = "" } + + // Declare a default namespace only when it changes relative to the parent. + let ns-str = if ns != inherited-ns { + " xmlns=\"" + esc-attr(if ns == none { "" } else { ns }) + "\"" + } else { + "" + } + + let open = "<" + tag + ns-str + attrs-str + + // Range covering this element's whole serialized text (added once the text + // length is known); descendants' ranges are shifted into this frame. + let self-range(text, ranges) = if record { + (((path: path, start: 0, end: text.len()),) + ranges) + } else { () } + + // Math extraction: replace the (string-serialized) children with a + // sentinel and collect the actual equation content under a fresh id. + if extract and "math" in node { + let id = "math-" + str(math.len()) + math.insert(id, node.math) + let text = open + ">⟦" + id + "⟧" + return (text: text, math: math, ranges: self-range(text, ())) + } + + if children.len() == 0 { + let text = open + " />" + return (text: text, math: math, ranges: self-range(text, ())) + } + + // Pretty-print only when every child is an element node: reformatting + // mixed content (any text child) would inject significant whitespace. + let element-only = pretty and children.all(c => + type(c) == dictionary and c.at("tag", default: "") != "") + + let opengt = open + ">" + let inner = "" + let ranges = () + let off = opengt.len() + let i = 0 + if element-only { + let child-indent = _indent-unit * (depth + 1) + for c in children { + let r = _serialize(c, ns, handlers, extract, pretty, depth + 1, math, path + (i,), record) + let prefix = "\n" + child-indent + off += prefix.len() + if record { + for rg in r.ranges { ranges.push((path: rg.path, start: rg.start + off, end: rg.end + off)) } + } + inner += prefix + r.text + off += r.text.len() + math = r.math + i += 1 + } + let text = opengt + inner + "\n" + _indent-unit * depth + "" + (text: text, math: math, ranges: self-range(text, ranges)) + } else { + for c in children { + let r = _serialize(c, ns, handlers, extract, pretty, depth, math, path + (i,), record) + if record { + for rg in r.ranges { ranges.push((path: rg.path, start: rg.start + off, end: rg.end + off)) } + } + inner += r.text + off += r.text.len() + math = r.math + i += 1 + } + let text = opengt + inner + "" + (text: text, math: math, ranges: self-range(text, ranges)) + } +} + +/// Serialize XML nodes to an XML string; also faithfully reverses the output +/// of Typst's built-in `xml()` reader. +/// +/// For `xml()` reader output: +/// - Text nodes are re-escaped for text context. +/// - Attribute values are re-escaped for attribute context; attribute order is +/// preserved. +/// - Namespaces are emitted as default `xmlns="..."` declarations, but only +/// when an element's resolved namespace URI differs from the one it inherits +/// from its parent (mirroring how `xml()` repeats the URI on every +/// descendant). Original prefixes and attribute namespaces are not +/// recoverable from `xml()` output and are not reconstructed. +/// - Elements with no children are written self-closing (``). +/// - Comment and processing-instruction nodes (which `xml()` collapses to an +/// empty-tag sentinel with `tag == ""` and no recoverable content) are +/// skipped. +/// +/// Accepts a single node, an array of nodes (as returned directly by +/// `xml("file.xml")`), or content -- e.g. the direct return value of an xmlit +/// tag function, or a markup block, normalized via `convert` (`handlers:` is +/// forwarded to it). +/// +/// With `extract-math: true`, returns a dictionary `(xml: str, math-items: +/// dictionary)` instead of a plain string: `xml` is the XML string with each +/// equation's content replaced by a text sentinel `⟦math-N⟧`, and +/// `math-items` maps each id ("math-0", "math-1", ... in document order) to +/// the actual Typst equation content -- ready to be rendered or `measure()`d. +/// Equation nodes are recognized by the reserved `math` key that the built-in +/// `equation` handler stores on them. +/// +/// With `pretty-print: true`, elements whose children are all elements (no +/// text nodes) are indented with each child on its own line. Elements with +/// any text child (mixed content) stay inline, so no significant whitespace +/// is introduced. Note that pretty-printed output is not byte-faithful to +/// `xml()` reader input -- it is for human reading, not round-tripping. +/// +/// Example: +/// #xml-to-string(xml("doc.xml")) +/// #xml-to-string(foo(bar(baz: "zz"))) +/// #xml-to-string(foo(bar(baz: "zz")), pretty-print: true) +/// #let (xml, math-items) = xml-to-string(doc, extract-math: true) +/// #context math-items.pairs().map(((id, eq)) => (id, measure(eq))) +#let xml-to-string( + node, + inherited-ns: none, + handlers: auto, + extract-math: false, + pretty-print: false, +) = { + let r = _serialize(node, inherited-ns, handlers, extract-math, pretty-print, 0, (:), (), false) + if extract-math { + (xml: r.text, math-items: r.math) + } else { + r.text + } +} + +/// Like `xml-to-string`, but also returns per-element source ranges. Returns +/// `(xml: str, ranges: array)` where each `ranges` entry is +/// `(path: (int,), start: int, end: int)`: `path` is the element's sequence of +/// child indices from the root and `start`/`end` are byte offsets of its +/// serialized text within `xml`. +/// +/// Used to map a validator's byte-offset error position back to the specific +/// element that produced it (see `create-from-relaxng`'s +/// `render-and-show-validation-errors`). Serialize compact (the default) to +/// locate against a validator's positions; serialize with `pretty-print: true` +/// to find the same element's line in a human-readable rendering (the `path` +/// is stable across both). +#let xml-to-string-with-ranges( + node, + inherited-ns: none, + handlers: auto, + pretty-print: false, +) = { + let r = _serialize(node, inherited-ns, handlers, false, pretty-print, 0, (:), (), true) + (xml: r.text, ranges: r.ranges) +} diff --git a/packages/preview/xmlit/0.1.3/typst.toml b/packages/preview/xmlit/0.1.3/typst.toml new file mode 100644 index 0000000000..e9b277a85d --- /dev/null +++ b/packages/preview/xmlit/0.1.3/typst.toml @@ -0,0 +1,20 @@ +[package] +name = "xmlit" +version = "0.1.3" +compiler = "0.15.0" +repository = "https://github.com/siefkenj/typst-xmlit" +entrypoint = "src/lib.typ" +authors = ["Jason Siefken "] +categories = ["utility"] +license = "MIT" +description = "Generate XML documents using native syntax, serialize, and validate with RelaxNG schemas." +keywords = ["xml", "serialization", "markup", "relaxng", "relax-ng", "schema"] +exclude = [ + "tests", + "plugin", + "src/elem/elem.test.typ", + "src/relaxng/relaxng.test.typ", +] + +[tool.tytanic] +tests = "tests"