- commit
- 04a59f11928fa10d357a882b812041463b7a1fad
- parent
- d3b87c1fcf1fc14be5af21f55573624664d7b9ce
- author
- Dami <dami.etoile@gmail.com>
| M | diagramzip-render/ | 4 | +- |
| M | diagramzip-render/ | 3 | +- |
| M | diagramzip-render/ | 2 | +- |
| M | diagramzip-render/ | 7 | +- |
| M | diagramzip-svg/ | 8 | ++ |
| M | diagramzip-svg/ | 154 | +++++++++++++++++++++++++++++- |
| M | diagramzip/ | 2 | +- |
| M | diagramzip/ | 53 | ++++++++++ |
| M | docs/ | 21 | ++++- |
@@ -1,5 +1,5 @@
11 import { ENGINE_IDS, type EngineId, type EngineRuntime } from './types'2-import { RAW_NORMALIZATION, type NormalizationCapability } from '../../diagramzip-svg/index.js'2+import { normalizationFor, type NormalizationCapability } from '../../diagramzip-svg/index.js'33 44 export interface EngineCatalogEntry {55 id: EngineId@@ -221,7 +221,7 @@ export const ENGINE_CATALOG: readonly EngineCatalogEntry[] = ENGINE_IDS.map(id =
221221 activeRuntime: activeRuntime[id],222222 version: versions[id],223223 knownLosses: losses[id] ?? [],224- normalization: RAW_NORMALIZATION,224+ normalization: normalizationFor(id, versions[id]),225225 }))226226 227227 export const ENGINE_CATALOG_BY_ID = new Map(ENGINE_CATALOG.map(entry => [entry.id, entry]))@@ -10,9 +10,10 @@ export function sanitizeAndDecorateSvg(
1010 metadata: RenderMetadata,1111 presentation: RenderPresentation,1212 engine: EngineId,13+ rendererVersion = '',1314 ): string {1415 try {15- return normalizeSharedSvg(source, metadata, presentation, engine)16+ return normalizeSharedSvg(source, metadata, presentation, engine, rendererVersion)1617 } catch (error) {1718 if (error instanceof SvgNormalizationError) {1819 throw new RenderError(error.status, error.code, error.message)@@ -76,7 +76,7 @@ async function render(
7676 ): Promise<Response> {7777 const rendered = await descriptor.adapter.render(request, signal)7878 if (signal.aborted) throw signal.reason79- const body = sanitizeAndDecorateSvg(rendered.body, request.metadata, request.presentation, descriptor.id)79+ const body = sanitizeAndDecorateSvg(rendered.body, request.metadata, request.presentation, descriptor.id, rendered.engineVersion)8080 return new Response(body, {8181 headers: {8282 'Access-Control-Allow-Origin': '*',@@ -10,7 +10,7 @@ describe('engine catalog', () => {
1010 })1111 1212 it('reports raw-only normalization until an engine profile earns themed support', () => {13- for (const engine of ENGINE_CATALOG) {13+ for (const engine of ENGINE_CATALOG.filter(entry => entry.id !== 'graphviz')) {1414 expect(engine.normalization).toMatchObject({1515 schema: '1',1616 normalizer: 'svg-normalizer-1',@@ -19,6 +19,11 @@ describe('engine catalog', () => {
1919 appearances: ['raw'],2020 })2121 }22+ expect(ENGINE_CATALOG.find(entry => entry.id === 'graphviz')?.normalization).toMatchObject({23+ profile: 'graphviz-15-semantic-1',24+ conformance: 'semantic',25+ appearances: expect.arrayContaining(['raw', 'auto-transparent', 'dark-framed']),26+ })2227 })2328 2429 it('marks the BlockDiag family as edge Python', () => {@@ -1,5 +1,7 @@
11 export const SVG_SCHEMA: '1'22 export const NORMALIZER_BUILD: 'svg-normalizer-1'3+export const MATERIALIZER_BUILD: 'svg-materializer-1'4+export const PALETTE_BUILD: 'diagramzip-palette-1'35 export const RAW_PROFILE: 'safe-raw-1'46 export const APPEARANCES: readonly [57 'raw',@@ -17,8 +19,10 @@ export interface NormalizationCapability {
1719 readonly palette: string1820 readonly conformance: 'raw' | 'semantic' | 'adaptive' | 'presentation-only' | 'unsupported'1921 readonly appearances: readonly string[]22+ readonly limitations?: readonly string[]2023 }2124 export const RAW_NORMALIZATION: NormalizationCapability25+export function normalizationFor(engine: string, rendererVersion?: string): NormalizationCapability2226 2327 export class SvgNormalizationError extends Error {2428 readonly status: number@@ -42,4 +46,8 @@ export function sanitizeAndDecorateSvg(
4246 metadata: SvgMetadata,4347 presentation: SvgPresentation,4448 engine: string,49+ rendererVersion?: string,4550 ): string51+52+export type SvgAppearance = typeof APPEARANCES[number]53+export function materializeSvg(canonical: string, appearance: SvgAppearance): string@@ -10,6 +10,8 @@ const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\
1010 1111 export const SVG_SCHEMA = '1'1212 export const NORMALIZER_BUILD = 'svg-normalizer-1'13+export const MATERIALIZER_BUILD = 'svg-materializer-1'14+export const PALETTE_BUILD = 'diagramzip-palette-1'1315 export const RAW_PROFILE = 'safe-raw-1'1416 export const APPEARANCES = Object.freeze([1517 'raw',@@ -28,6 +30,21 @@ export const RAW_NORMALIZATION = Object.freeze({
2830 conformance: 'raw',2931 appearances: Object.freeze(['raw']),3032 })33+const THEMED_APPEARANCES = Object.freeze(APPEARANCES.filter(appearance => appearance !== 'raw'))34+const GRAPHVIZ_NORMALIZATION = Object.freeze({35+ schema: SVG_SCHEMA,36+ normalizer: NORMALIZER_BUILD,37+ profile: 'graphviz-15-semantic-1',38+ palette: PALETTE_BUILD,39+ conformance: 'semantic',40+ appearances: APPEARANCES,41+ limitations: Object.freeze(['Authored non-neutral colors are preserved and may not adapt between palettes.']),42+})43+44+export function normalizationFor(engine, rendererVersion = '') {45+ if (engine === 'graphviz' && rendererVersion.startsWith('graphviz@15.1.1')) return GRAPHVIZ_NORMALIZATION46+ return RAW_NORMALIZATION47+}3148 3249 export class SvgNormalizationError extends Error {3350 constructor(status, code, message) {@@ -156,6 +173,42 @@ function removeRendererBackdrops(element, inheritedBounds, engine) {
156173 })157174 }158175 176+function neutralPaint(element, property, accepted) {177+ const value = (element.attributes.get(property) ?? styleValue(element, property) ?? '').replaceAll(' ', '').toLowerCase()178+ return accepted.has(value)179+}180+181+function applyGraphvizProfile(root) {182+ const visit = (node, context = '') => {183+ if (node.type === 'text') return184+ const classes = new Set((node.attributes.get('class') ?? '').toLowerCase().split(/\s+/).filter(Boolean))185+ const nextContext = classes.has('node') ? 'node' : classes.has('edge') ? 'edge' : classes.has('cluster') ? 'cluster' : context186+ const name = localName(node)187+ if (rendererBackdrop(node, presentationBounds(root), 'graphviz')) node.attributes.set('data-dz-role', 'canvas')188+ if (name === 'text' && neutralPaint(node, 'fill', new Set(['', 'black', '#000', '#000000', 'rgb(0,0,0)']))) {189+ node.attributes.set('data-dz-fill', nextContext === 'edge' ? 'ink-muted' : 'ink')190+ }191+ if (nextContext === 'node' && ['ellipse', 'polygon', 'path', 'rect'].includes(name)) {192+ if (neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) node.attributes.set('data-dz-fill', 'surface-1')193+ if (neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line')194+ }195+ if (nextContext === 'cluster' && ['polygon', 'path', 'rect'].includes(name)) {196+ if (neutralPaint(node, 'fill', new Set(['', 'none', 'white', '#fff', '#ffffff']))) node.attributes.set('data-dz-fill', 'surface-2')197+ if (neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line-muted')198+ }199+ if (nextContext === 'edge') {200+ if (['path', 'polyline', 'line'].includes(name) && neutralPaint(node, 'stroke', new Set(['', 'black', '#000', '#000000']))) node.attributes.set('data-dz-stroke', 'line')201+ if (['polygon', 'path'].includes(name) && neutralPaint(node, 'fill', new Set(['black', '#000', '#000000']))) node.attributes.set('data-dz-fill', 'line')202+ }203+ for (const child of node.children) visit(child, nextContext)204+ }205+ visit(root)206+}207+208+function applyProfile(root, capability) {209+ if (capability.profile === 'graphviz-15-semantic-1') applyGraphvizProfile(root)210+}211+159212 function applyRootBackgroundStyle(root, background) {160213 const declarations = (root.attributes.get('style') ?? '')161214 .split(';')@@ -207,7 +260,7 @@ function decorate(root, metadata, presentation, engine) {
207260 if (frame) root.children.push(frame)208261 }209262 210-export function sanitizeAndDecorateSvg(source, metadata, presentation, engine) {263+function parseSafeSvg(source) {211264 if (source.length > MAX_SVG_LENGTH) throw new SvgNormalizationError(413, 'render_too_large', 'Rendered SVG is too large.')212265 let root = null213266 const stack = []@@ -262,13 +315,106 @@ export function sanitizeAndDecorateSvg(source, metadata, presentation, engine) {
262315 if (parseError !== null || root === null || localName(root) !== 'svg') {263316 throw new SvgNormalizationError(422, 'invalid_svg', 'Renderer returned invalid SVG.')264317 }318+ return root319+}320+321+export function sanitizeAndDecorateSvg(source, metadata, presentation, engine, rendererVersion = '') {322+ const root = parseSafeSvg(source)323+ const capability = normalizationFor(engine, rendererVersion)324+ applyProfile(root, capability)265325 root.attributes.set('data-dz-schema', SVG_SCHEMA)266326 root.attributes.set('data-dz-normalizer', NORMALIZER_BUILD)267- root.attributes.set('data-dz-profile', RAW_PROFILE)268- root.attributes.set('data-dz-palette', 'renderer')327+ root.attributes.set('data-dz-profile', capability.profile)328+ root.attributes.set('data-dz-palette', capability.palette)269329 root.attributes.set('data-dz-engine', engine)270330 root.attributes.set('data-dz-appearance', 'raw')271- root.attributes.set('data-dz-conformance', 'raw')331+ root.attributes.set('data-dz-conformance', capability.conformance)332+ if (capability !== RAW_NORMALIZATION) root.attributes.set('data-dz-appearances', THEMED_APPEARANCES.join(' '))333+ const bounds = presentationBounds(root)334+ if (bounds !== null) root.attributes.set('data-dz-bounds', bounds.join(' '))272335 decorate(root, metadata, presentation, engine)273336 return serialize(root)274337 }338+339+const LIGHT_PALETTE = Object.freeze({340+ canvas: '#f8fafc', surface1: '#ffffff', surface2: '#f1f5f9', surface3: '#e2e8f0',341+ ink: '#0f172a', inkMuted: '#475569', line: '#334155', lineMuted: '#94a3b8',342+ accent1: '#2563eb', accent2: '#7c3aed', accent3: '#c2410c', onAccent: '#ffffff', frame: '#cbd5e1',343+})344+const DARK_PALETTE = Object.freeze({345+ canvas: '#0f172a', surface1: '#1e293b', surface2: '#273449', surface3: '#334155',346+ ink: '#f8fafc', inkMuted: '#cbd5e1', line: '#e2e8f0', lineMuted: '#94a3b8',347+ accent1: '#60a5fa', accent2: '#c4b5fd', accent3: '#fb923c', onAccent: '#0f172a', frame: '#475569',348+})349+350+function paletteVariables(palette) {351+ return `--dz-canvas:${palette.canvas};--dz-surface-1:${palette.surface1};--dz-surface-2:${palette.surface2};--dz-surface-3:${palette.surface3};--dz-ink:${palette.ink};--dz-ink-muted:${palette.inkMuted};--dz-line:${palette.line};--dz-line-muted:${palette.lineMuted};--dz-accent-1:${palette.accent1};--dz-accent-2:${palette.accent2};--dz-accent-3:${palette.accent3};--dz-on-accent:${palette.onAccent};--dz-frame:${palette.frame};`352+}353+354+function materializerCss(appearance) {355+ const scheme = appearance.startsWith('dark-') ? DARK_PALETTE : LIGHT_PALETTE356+ const automatic = appearance.startsWith('auto-')357+ const variables = automatic358+ ? `:root{${paletteVariables(LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){:root{${paletteVariables(DARK_PALETTE)}}}`359+ : `:root{${paletteVariables(scheme)}}`360+ return `${variables}[data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}[data-dz-fill="surface-1"]{fill:var(--dz-surface-1)!important}[data-dz-fill="surface-2"]{fill:var(--dz-surface-2)!important}[data-dz-fill="surface-3"]{fill:var(--dz-surface-3)!important}[data-dz-fill="ink"]{fill:var(--dz-ink)!important;color:var(--dz-ink)!important}[data-dz-fill="ink-muted"]{fill:var(--dz-ink-muted)!important;color:var(--dz-ink-muted)!important}[data-dz-fill="line"]{fill:var(--dz-line)!important}[data-dz-fill="accent-1"]{fill:var(--dz-accent-1)!important}[data-dz-fill="accent-2"]{fill:var(--dz-accent-2)!important}[data-dz-fill="accent-3"]{fill:var(--dz-accent-3)!important}[data-dz-fill="on-accent"]{fill:var(--dz-on-accent)!important;color:var(--dz-on-accent)!important}[data-dz-stroke="line"]{stroke:var(--dz-line)!important}[data-dz-stroke="line-muted"]{stroke:var(--dz-line-muted)!important}[data-dz-stroke="accent-1"]{stroke:var(--dz-accent-1)!important}[data-dz-stroke="accent-2"]{stroke:var(--dz-accent-2)!important}[data-dz-stroke="accent-3"]{stroke:var(--dz-accent-3)!important}`361+}362+363+function canonicalBounds(root) {364+ const value = root.attributes.get('data-dz-bounds')?.trim().split(/\s+/).map(Number)365+ if (value?.length === 4 && value.every(Number.isFinite) && value[2] > 0 && value[3] > 0) return value366+ return presentationBounds(root)367+}368+369+function removeMaterializerOutput(root) {370+ root.children = root.children.filter(child => child.type === 'text' || child.attributes.get('data-dz-owned') !== 'materializer')371+}372+373+export function materializeSvg(canonical, appearance) {374+ if (!APPEARANCES.includes(appearance)) {375+ throw new SvgNormalizationError(400, 'invalid_appearance', `Unknown SVG appearance: ${appearance}.`)376+ }377+ const root = parseSafeSvg(canonical)378+ if (root.attributes.get('data-dz-schema') !== SVG_SCHEMA) {379+ throw new SvgNormalizationError(422, 'not_canonical', 'SVG does not use the current Diagram.zip canonical schema.')380+ }381+ removeMaterializerOutput(root)382+ if (appearance === 'raw') {383+ root.attributes.set('data-dz-appearance', 'raw')384+ root.attributes.set('data-dz-palette', 'renderer')385+ root.attributes.delete('data-dz-materializer')386+ return serialize(root)387+ }388+ const supported = new Set((root.attributes.get('data-dz-appearances') ?? '').split(/\s+/).filter(Boolean))389+ if (!supported.has(appearance)) {390+ throw new SvgNormalizationError(422, 'unsupported_appearance', `The active SVG normalization profile does not support ${appearance}.`)391+ }392+ const bounds = canonicalBounds(root)393+ if (bounds === null) throw new SvgNormalizationError(422, 'missing_dimensions', 'Canonical SVG has no usable bounds for appearance materialization.')394+ root.attributes.set('data-dz-appearance', appearance)395+ root.attributes.set('data-dz-palette', PALETTE_BUILD)396+ root.attributes.set('data-dz-materializer', MATERIALIZER_BUILD)397+ root.children.unshift(element('style', [['data-dz-owned', 'materializer']], [{ type: 'text', value: materializerCss(appearance) }]))398+ if (appearance.endsWith('-framed')) {399+ const [x, y, width, height] = bounds400+ const padding = 24401+ const next = [x - padding, y - padding, width + padding * 2, height + padding * 2]402+ root.attributes.set('viewBox', next.join(' '))403+ if (root.attributes.has('width')) root.attributes.set('width', String(next[2]))404+ if (root.attributes.has('height')) root.attributes.set('height', String(next[3]))405+ root.children.splice(1, 0, element('rect', [406+ ['data-dz-owned', 'materializer'], ['data-dz-role', 'canvas'], ['x', String(next[0])], ['y', String(next[1])],407+ ['width', String(next[2])], ['height', String(next[3])], ['fill', 'var(--dz-canvas)'],408+ ]))409+ root.children.push(element('rect', [410+ ['data-dz-owned', 'materializer'], ['data-dz-role', 'frame'], ['x', String(next[0] + 0.5)], ['y', String(next[1] + 0.5)],411+ ['width', String(next[2] - 1)], ['height', String(next[3] - 1)], ['fill', 'none'], ['stroke', 'var(--dz-frame)'],412+ ['stroke-width', '1'], ['vector-effect', 'non-scaling-stroke'],413+ ]))414+ } else {415+ root.attributes.set('viewBox', bounds.join(' '))416+ if (root.attributes.has('width')) root.attributes.set('width', String(bounds[2]))417+ if (root.attributes.has('height')) root.attributes.set('height', String(bounds[3]))418+ }419+ return serialize(root)420+}@@ -95,7 +95,7 @@ export class PreviewController {
9595 if (clientAdapter) {9696 const clientRender = await clientAdapter.render({ type, source, options }, abortController.signal)9797 rendered = {98- body: sanitizeAndDecorateSvg(clientRender.body, meta, presentation, type),98+ body: sanitizeAndDecorateSvg(clientRender.body, meta, presentation, type, clientRender.version || clientRender.build || ''),9999 identity: {100100 unit: type,101101 build: clientRender.build || clientRender.version,@@ -4,6 +4,7 @@ import {
44 APPEARANCES,55 RAW_NORMALIZATION,66 SvgNormalizationError,7+ materializeSvg,78 sanitizeAndDecorateSvg,89 } from '../../diagramzip-svg/index.js'910 @@ -44,3 +45,55 @@ test('exposes structured normalization failures to both callers', () => {
4445 error => error instanceof SvgNormalizationError && error.code === 'unsafe_svg' && error.status === 422,4546 )4647 })48+49+const semanticCanonical = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" width="10" height="10" data-dz-schema="1" data-dz-normalizer="svg-normalizer-1" data-dz-profile="fixture-semantic-1" data-dz-palette="renderer" data-dz-engine="fixture" data-dz-appearance="raw" data-dz-conformance="semantic" data-dz-bounds="0 0 10 10" data-dz-appearances="auto-transparent light-transparent dark-transparent auto-framed light-framed dark-framed"><rect x="1" y="1" width="8" height="8" fill="white" stroke="black" data-dz-fill="surface-1" data-dz-stroke="line"></rect><text x="5" y="6" fill="black" data-dz-fill="ink">A</text></svg>'50+51+test('materializes explicit and automatic palettes without changing diagram geometry', () => {52+ const light = materializeSvg(semanticCanonical, 'light-transparent')53+ const dark = materializeSvg(semanticCanonical, 'dark-transparent')54+ const automatic = materializeSvg(semanticCanonical, 'auto-transparent')55+56+ for (const output of [light, dark, automatic]) {57+ assert.match(output, /data-dz-materializer="svg-materializer-1"/)58+ assert.match(output, /data-dz-palette="diagramzip-palette-1"/)59+ assert.match(output, /<rect x="1" y="1" width="8" height="8"/)60+ assert.doesNotMatch(output, /<(?:rect|path|polygon)[^>]*data-dz-role="canvas"/)61+ }62+ assert.match(light, /--dz-ink:#0f172a/)63+ assert.match(dark, /--dz-ink:#f8fafc/)64+ assert.match(automatic, /@media\(prefers-color-scheme:dark\)/)65+})66+67+test('materializes a bounded frame and remains idempotent', () => {68+ const framed = materializeSvg(semanticCanonical, 'dark-framed')69+ const repeated = materializeSvg(framed, 'dark-framed')70+71+ assert.equal(framed, repeated)72+ assert.match(framed, /viewBox="-24 -24 58 58"/)73+ assert.match(framed, /data-dz-role="canvas"/)74+ assert.match(framed, /data-dz-role="frame"/)75+ assert.match(framed, /vector-effect="non-scaling-stroke"/)76+})77+78+test('fails closed when a raw-only profile requests a themed appearance', () => {79+ const raw = sanitizeAndDecorateSvg(source, metadata, presentation, 'pikchr')80+ assert.throws(81+ () => materializeSvg(raw, 'dark-framed'),82+ error => error instanceof SvgNormalizationError && error.code === 'unsupported_appearance',83+ )84+})85+86+test('applies the pinned GraphViz semantic profile without recoloring authored paint', () => {87+ const graphviz = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50"><g class="graph"><polygon fill="white" stroke="none" points="0,0 100,0 100,50 0,50"></polygon><g class="node"><ellipse fill="none" stroke="black" cx="25" cy="25" rx="20" ry="10"></ellipse><text fill="black">A</text></g><g class="node"><ellipse fill="red" stroke="red" cx="75" cy="25" rx="20" ry="10"></ellipse><text fill="white">B</text></g><g class="edge"><path fill="none" stroke="black" d="M45 25L55 25"></path><polygon fill="black" stroke="black" points="55,25 50,20 50,30"></polygon></g></g></svg>'88+ const canonical = sanitizeAndDecorateSvg(graphviz, metadata, presentation, 'graphviz', 'graphviz@15.1.1')89+ const dark = materializeSvg(canonical, 'dark-transparent')90+91+ assert.match(canonical, /data-dz-profile="graphviz-15-semantic-1"/)92+ assert.match(canonical, /data-dz-conformance="semantic"/)93+ assert.match(canonical, /data-dz-role="canvas"/)94+ assert.match(canonical, /data-dz-fill="surface-1"/)95+ assert.match(canonical, /data-dz-stroke="line"/)96+ assert.match(dark, /data-dz-role="canvas"\]:not/)97+ assert.match(dark, /fill="red" stroke="red"/)98+ assert.doesNotMatch(dark, /fill="red" stroke="red"[^>]*data-dz-/)99+})@@ -141,8 +141,8 @@ Schema 1 uses the following root attributes:
141141 data-dz-appearance="light-transparent">142142 <style>/* self-contained tokens and role rules */</style>143143 <g data-dz-role="node">144- <rect data-dz-paint="surface-1" />145- <text data-dz-paint="ink">Service</text>144+ <rect data-dz-fill="surface-1" data-dz-stroke="line" />145+ <text data-dz-fill="ink">Service</text>146146 </g>147147 </svg>148148 ```@@ -168,6 +168,23 @@ The normalizer may preserve original IDs when safe. Any IDs it creates must be
168168 deterministically namespaced to avoid collisions with engine IDs, paint169169 servers, masks, markers, filters, or embedded SVG fragments.170170 171+Schema 1 separates fill and stroke roles. `data-dz-fill` controls paint inside172+an element, while `data-dz-stroke` controls its boundary or connector. This173+keeps arrowheads, unfilled nodes, and line geometry from inheriting the wrong174+property during materialization.175+176+## Current profile coverage177+178+The shared sanitizer, canonical serializer, and appearance materializer apply179+to browser and Worker renderers. Every catalog entry reports its active180+normalization capability. An unrecognized renderer contract remains on181+`safe-raw-1` and supports only `raw`.182+183+GraphViz `15.1.1` currently uses `graphviz-15-semantic-1`. The profile relies on184+GraphViz's stable `graph`, `node`, `edge`, and `cluster` groups. It adapts neutral185+surfaces, text, connectors, and arrowheads. Explicit non-neutral authored colors186+remain unchanged and are reported as a profile limitation.187+171188 ## Semantic visual roles172189 173190 The normalized palette is expressed through semantic roles rather than literal