/**
* @copyright Sister Software
* @license AGPL-3.0
* @author Teffen Ellis, et al.
*
* XML projection — nested mixed-content with attributes.
*
* Each component is one element. The element's _direct text node_ is the component's own value
* (e.g. `Paris…` — "Paris" is the locality's text). Children are nested as
* sub-elements representing geographic / structural containment.
*
* Attributes:
*
* - `conf` — aggregated confidence in [0, 1], two decimal places.
* - `start` / `end` — character offsets in the raw input. Preserves source order alongside the
* containment-derived element order.
* - `src` — provenance for the assertion. Formatted as `:` when both fields are
* present on the node, `` when only the broad category is set, omitted when neither
* is. Phase 4.1 surfaces classifier provenance (`rule:whos_on_first`, `neural:v0.3.1-en-us`);
* Phase 4.3 overlays resolver provenance (`resolver:wof-admin:101751119`).
* - `lat` / `lon` — resolver-supplied centroid (Phase 4.3). Emitted only when both are set.
* - `place` — resolver-supplied normalized place URI like `wof:101751119` (Phase 4.3). Emitted only
* when `node.placeID` is set; distinct from `src` so callers that want the bare place id
* without the vendor prefix have a direct attribute to read.
* - Root `` carries `raw` — the full input string for round-trip.
*
* ⚠ DOM gotcha: `element.textContent` on a mixed-content node returns the concatenation of all
* descendant text (parent value + children values). Use `Array.from(el.childNodes).filter(n =>
* n.nodeType === 3).map(n => n.nodeValue).join('').trim()` or XPath `text()` to get just the
* parent's own value. Documented in the package README.
*/
import type { AddressNode, AddressTree } from "./types.ts"
import { unknownSpans } from "./unknown-spans.ts"
export interface SerializeXMLOpts {
/**
* Pretty-print with line breaks and indentation. Default true.
*/
pretty?: boolean
/**
* Include `conf` attribute on every component. Default true.
*/
includeConf?: boolean
/**
* Include `start` + `end` char-offset attributes. Default true.
*/
includeOffsets?: boolean
/**
* Include `src` provenance attribute when the node carries source info. Default true.
*/
includeSrc?: boolean
/**
* Include `lat` + `lon` resolver-supplied centroid attrs when set on the node. Default true.
*/
includeGeo?: boolean
/**
* Include `place` resolver-supplied normalized place URI when set. Default true.
*/
includePlace?: boolean
/**
* Include `` child elements for each runner-up resolver candidate on the node. When set +
* node.alternatives is populated, each runner-up is emitted as a self-closing element with `place`, `name`, `lat`,
* `lon`, `score` attributes. Default false — keeps output libpostal-compat when not explicitly requested
* (Springfield-class disambiguation surfaces only when the caller asks).
*/
includeAlternatives?: boolean
/**
* Emit `…` elements for the all-O runs no node covers — the input the model left
* unclassified (#493 lossless decomposition). Interleaved with the root components in source order, so the
* `` children tile the raw input exactly. Default false — keeps output libpostal-compat / the existing shape
* when not explicitly requested (same posture as {@link includeAlternatives}).
*/
includeUnknown?: boolean
}
function escapeXml(s: string): string {
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """)
}
function srcAttrValue(node: AddressNode): string | null {
if (node.source && node.sourceID) return `${node.source}:${node.sourceID}`
if (node.source) return node.source
if (node.sourceID) return node.sourceID
return null
}
/**
* Centroid precision for resolver-supplied lat/lon. 6 decimal places is ~11 cm at the equator — more than enough for
* any postal-address resolver and short enough to stay readable.
*/
const GEO_PRECISION = 6
function attrs(node: AddressNode, opts: Required): string {
const parts: string[] = []
if (opts.includeOffsets) {
parts.push(`start="${node.start}"`, `end="${node.end}"`)
}
if (opts.includeConf) {
parts.push(`conf="${node.confidence.toFixed(2)}"`)
}
if (opts.includeSrc) {
const src = srcAttrValue(node)
if (src !== null) {
parts.push(`src="${escapeXml(src)}"`)
}
}
// Emit lat + lon together — a centroid is meaningless with only one coordinate. Resolvers that
// can produce one but not the other shouldn't decorate the node at all.
if (opts.includeGeo && node.lat !== undefined && node.lon !== undefined) {
parts.push(`lat="${node.lat.toFixed(GEO_PRECISION)}"`, `lon="${node.lon.toFixed(GEO_PRECISION)}"`)
}
if (opts.includePlace && node.placeID !== undefined) {
parts.push(`place="${escapeXml(node.placeID)}"`)
}
// Multi-role node (#413): a city-state span tagged `region` that also plays `locality` lists every
// role it holds, primary first — `roles="region locality"`. Emitted only when extra roles exist.
if (node.interpretations && node.interpretations.length) {
const roles = [node.tag, ...node.interpretations.map((i) => i.tag)]
parts.push(`roles="${escapeXml(roles.join(" "))}"`)
}
return !parts.length ? "" : " " + parts.join(" ")
}
interface AlternativeLike {
id: number | string
name: string
placetype: string
lat: number
lon: number
score: number
}
function serializeAlternatives(node: AddressNode, indent: string): string {
if (!node.alternatives || !node.alternatives.length) return ""
const lines = node.alternatives.map((raw) => {
const alt = raw as AlternativeLike
const place = `wof:${alt.id}`
const parts = [
`place="${escapeXml(place)}"`,
`name="${escapeXml(alt.name)}"`,
`placetype="${escapeXml(alt.placetype)}"`,
`lat="${alt.lat.toFixed(GEO_PRECISION)}"`,
`lon="${alt.lon.toFixed(GEO_PRECISION)}"`,
`score="${alt.score.toFixed(3)}"`,
]
return `${indent}`
})
return lines.join("\n")
}
function serializeNode(node: AddressNode, indent: string, opts: Required): string {
const a = attrs(node, opts)
const text = escapeXml(node.value)
const nl = opts.pretty ? "\n" : ""
const childIndent = opts.pretty ? indent + "\t" : ""
const altsBlock = opts.includeAlternatives ? serializeAlternatives(node, childIndent) : ""
const hasChildren = node.children.length > 0
const hasAlts = altsBlock.length > 0
if (!hasChildren && !hasAlts) {
return `${indent}<${node.tag}${a}>${text}${node.tag}>`
}
const childrenStr = node.children.map((c) => serializeNode(c, childIndent, opts)).join(nl)
const inner = [childrenStr, altsBlock].filter((part) => part.length > 0).join(nl)
return `${indent}<${node.tag}${a}>${text}${nl}${inner}${nl}${indent}${node.tag}>`
}
/**
* Project an `AddressTree` to nested XML with optional confidence/offset attributes.
*/
export function decodeAsXML(tree: AddressTree, opts: SerializeXMLOpts = {}): string {
const full: Required = {
pretty: opts.pretty ?? true,
includeConf: opts.includeConf ?? true,
includeOffsets: opts.includeOffsets ?? true,
includeSrc: opts.includeSrc ?? true,
includeGeo: opts.includeGeo ?? true,
includePlace: opts.includePlace ?? true,
includeAlternatives: opts.includeAlternatives ?? false,
includeUnknown: opts.includeUnknown ?? false,
}
const rawAttr = escapeXml(tree.raw)
const nl = full.pretty ? "\n" : ""
const indent = full.pretty ? "\t" : ""
// Source-ordered XML for the root components. When includeUnknown is set, interleave the all-O gaps as
// `` elements by start offset so the children tile the raw input losslessly.
const entries: Array<{ start: number; xml: string }> = tree.roots.map((r) => ({
start: r.start,
xml: serializeNode(r, indent, full),
}))
if (full.includeUnknown) {
for (const u of unknownSpans(tree)) {
entries.push({
start: u.start,
xml: `${indent}${escapeXml(u.value)}`,
})
}
entries.sort((a, b) => a.start - b.start)
}
const children = entries.map((e) => e.xml).join(nl)
return `${nl}${children}${nl}`
}