import { CSSFontFaceRule } from '@acemir/cssom' import { parseFragment, serialize, type DefaultTreeAdapterTypes } from 'parse5' import { decodeBase64 } from '@open-pencil/core/bytes' import { normalizeFontFamily } from '@open-pencil/core/text' import { exportWebFontFaceAssets, type WebFontFaceAsset, type WebFontFaceRequest } from '@open-pencil/core/text/web-font/assets' import { mergeClassNames, serializeHTML, splitWhitespace } from './serialize' import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types' export interface ExportHTMLBundleOptions { html?: 'fragment' | 'standalone' style?: 'inline' | 'tailwind' assets?: 'inline' | 'external' fonts?: 'assets' | 'none' assetBasePath?: string } export interface ExportHTMLFile { path: string content: string | Uint8Array } export interface ExportHTMLBundle { entrypoint: string files: ExportHTMLFile[] } interface StandaloneBounds { minX: number minY: number width: number height: number } type ParseNode = DefaultTreeAdapterTypes.Node type ParseParent = DefaultTreeAdapterTypes.ParentNode type ParseElement = DefaultTreeAdapterTypes.Element const RESET_CSS = '*,*::before,*::after{box-sizing:border-box}html,body{margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#fff}' function styleToCSS(style: DesignStyleDeclaration): string { return Object.entries(style) .filter(([, value]) => value !== '') .map(([property, value]) => `${property}: ${value}`) .join('; ') } function cloneNode(node: DesignNode): DesignNode { if (node.type === 'text') return { ...node } return { ...node, attrs: { ...node.attrs }, inlineStyle: node.inlineStyle ? { ...node.inlineStyle } : undefined, children: node.children.map(cloneNode) } } function standaloneStyleForNode( node: DesignElement, parent: DesignElement | undefined, origin: StandaloneBounds ): DesignStyleDeclaration { const style = { ...node.inlineStyle } const source = node.sourceSceneNode if (!source) return style style.position = 'absolute' style.left = `${source.x - (parent ? 0 : origin.minX)}px` style.top = `${source.y - (parent ? 0 : origin.minY)}px` return style } function standaloneNode( node: DesignNode, origin: StandaloneBounds, parent?: DesignElement ): DesignNode { if (node.type === 'text') return cloneNode(node) const standalone: DesignElement = { ...node, attrs: { ...node.attrs }, inlineStyle: standaloneStyleForNode(node, parent, origin), children: [] } standalone.children = node.children.map((child) => standaloneNode(child, origin, node)) return standalone } function nodeBounds(node: DesignNode): StandaloneBounds | undefined { if (node.type === 'text' || !node.sourceSceneNode) return undefined return { minX: node.sourceSceneNode.x, minY: node.sourceSceneNode.y, width: node.sourceSceneNode.width, height: node.sourceSceneNode.height } } function standaloneSize(document: DesignDocument): StandaloneBounds { const bounds = document.children .map(nodeBounds) .filter((value): value is NonNullable => value !== undefined) const minX = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minX)) : 0 const minY = bounds.length > 0 ? Math.min(...bounds.map((bound) => bound.minY)) : 0 const maxX = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minX + bound.width)) : 1 const maxY = bounds.length > 0 ? Math.max(...bounds.map((bound) => bound.minY + bound.height)) : 1 return { minX, minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) } } function standaloneDocument(document: DesignDocument, size: StandaloneBounds): DesignDocument { return { ...document, children: document.children.map((node) => standaloneNode(node, size)) } } function cssClassName(index: number): string { return `op-${index.toString(36)}` } function extractInlineStyles( node: DesignNode, rules: string[], nextIndex: { value: number } ): DesignNode { if (node.type === 'text') return node const children = node.children.map((child) => extractInlineStyles(child, rules, nextIndex)) if (!node.inlineStyle || Object.keys(node.inlineStyle).length === 0) return { ...node, children } const className = cssClassName(nextIndex.value) nextIndex.value += 1 rules.push(`.${className}{${styleToCSS(node.inlineStyle)}}`) return { ...node, attrs: { ...node.attrs, class: mergeClassNames(node.attrs.class, className) ?? className }, inlineStyle: undefined, children } } function isElement(node: ParseNode): node is ParseElement { return 'attrs' in node && 'tagName' in node } function walkParseTree(node: ParseNode | ParseParent, visit: (node: ParseElement) => void): void { if (isElement(node)) visit(node) if ('childNodes' in node) { for (const child of node.childNodes) walkParseTree(child, visit) } } function classNamesFromHTML(html: string): string[] { const fragment = parseFragment(html) const classes = new Set() walkParseTree(fragment, (element) => { const classAttr = element.attrs.find((attr) => attr.name === 'class') if (!classAttr) return for (const className of splitWhitespace(classAttr.value)) classes.add(className) }) return [...classes] } async function compileTailwindClasses(classNames: string[]): Promise { if (classNames.length === 0) return '' const [{ compile }, { readFile }] = await Promise.all([ import('tailwindcss'), import('node:fs/promises') ]) const [themeCSS, utilitiesCSS] = await Promise.all([ readFile(new URL(import.meta.resolve('tailwindcss/theme.css')), 'utf8'), readFile(new URL(import.meta.resolve('tailwindcss/utilities.css')), 'utf8') ]) const compiler = await compile(`${themeCSS}\n${utilitiesCSS}`) return compiler.build(classNames) } function stripFontFamilyQuotes(value: string): string { if (value.length < 2) return value const first = value[0] const last = value[value.length - 1] if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1) return value } function firstFontFamily(value: string): string { const commaIndex = value.indexOf(',') const raw = commaIndex !== -1 ? value.slice(0, commaIndex) : value return stripFontFamilyQuotes(raw.trim()) } function collectFontRequests(node: DesignNode, fonts: Map): void { if (node.type === 'text') return const source = node.sourceSceneNode if (source?.type === 'TEXT') { const family = normalizeFontFamily(firstFontFamily(source.fontFamily)) const style = source.italic ? 'italic' : 'normal' const key = `${family}|${source.fontWeight}|${style}` fonts.set(key, { family, weight: source.fontWeight, style }) } for (const child of node.children) collectFontRequests(child, fonts) } function serializeFontWeight(weight: string | number | [number, number]): string { return Array.isArray(weight) ? weight.join(' ') : String(weight) } function fontSourceValue(asset: WebFontFaceAsset): string { const url = new URL(asset.path, 'file:///') const escapedPath = url.pathname.slice(1) return ['url(', JSON.stringify(escapedPath), ') format(', JSON.stringify(asset.format), ')'].join( '' ) } function fontFaceCSS(asset: WebFontFaceAsset): string { const rule = new CSSFontFaceRule() rule.style.setProperty('font-family', JSON.stringify(asset.family)) rule.style.setProperty('src', fontSourceValue(asset)) rule.style.setProperty('font-weight', serializeFontWeight(asset.weight)) rule.style.setProperty('font-style', asset.style) rule.style.setProperty('font-display', asset.display ?? 'swap') if (asset.stretch) rule.style.setProperty('font-stretch', asset.stretch) if (asset.unicodeRange && asset.unicodeRange.length > 0) { rule.style.setProperty('unicode-range', asset.unicodeRange.join(',')) } return rule.cssText } async function fontFaceAssets( document: DesignDocument, options: Required ): Promise<{ css: string; files: ExportHTMLFile[] }> { if (options.fonts === 'none' || options.assets !== 'external') return { css: '', files: [] } const requests = new Map() for (const child of document.children) collectFontRequests(child, requests) const result = await exportWebFontFaceAssets({ fonts: [...requests.values()], assetBasePath: `${options.assetBasePath}/fonts` }) return { css: result.assets.map(fontFaceCSS).join(''), files: result.assets } } function dataImageParts(value: string): { mime: string; base64: string } | undefined { if (!value.startsWith('data:image/')) return undefined const marker = ';base64,' const markerIndex = value.indexOf(marker) if (markerIndex === -1) return undefined return { mime: value.slice('data:'.length, markerIndex), base64: value.slice(markerIndex + marker.length) } } function extensionForMime(mime: string): string { if (mime === 'image/jpeg') return 'jpg' if (mime === 'image/webp') return 'webp' if (mime === 'image/gif') return 'gif' if (mime === 'image/svg+xml') return 'svg' return 'png' } function extractImageAssets( html: string, assetBasePath: string ): { html: string; files: ExportHTMLFile[] } { const fragment = parseFragment(html) const files: ExportHTMLFile[] = [] const sources = new Map() walkParseTree(fragment, (element) => { const src = element.attrs.find((attr) => attr.name === 'src') if (!src) return const parts = dataImageParts(src.value) if (!parts) return const cachedPath = sources.get(src.value) if (cachedPath) { src.value = cachedPath return } const path = `${assetBasePath}/images/image-${sources.size + 1}.${extensionForMime(parts.mime)}` sources.set(src.value, path) files.push({ path, content: decodeBase64(parts.base64) }) src.value = path }) return { html: serialize(fragment), files } } function stylesheetLink(path: string): string { return `` } function styleTag(css: string): string { return css ? `` : '' } async function exportStandaloneHTML( document: DesignDocument, options: Required ): Promise { const size = standaloneSize(document) const doc = standaloneDocument(document, size) const stageCSS = `.op-stage{position:relative;width:${size.width}px;height:${size.height}px;overflow:hidden;background:transparent}` let body: string let css = `${RESET_CSS}${stageCSS}` if (options.style === 'tailwind') { body = serializeHTML(doc, { style: 'tailwind' }) css += await compileTailwindClasses(classNamesFromHTML(body)) } else { const rules: string[] = [] const index = { value: 0 } const styledDocument: DesignDocument = { ...doc, children: doc.children.map((node) => extractInlineStyles(node, rules, index)) } body = serializeHTML(styledDocument) css += rules.join('') } if (options.assets === 'external') { const [extracted, fonts] = await Promise.all([ Promise.resolve(extractImageAssets(body, options.assetBasePath)), fontFaceAssets(document, options) ]) body = extracted.html const cssPath = `${options.assetBasePath}/openpencil.css` const html = `${stylesheetLink(cssPath)}
${body}
` return { entrypoint: 'index.html', files: [ { path: 'index.html', content: html }, { path: cssPath, content: `${fonts.css}${css}` }, ...fonts.files, ...extracted.files ] } } const html = `${styleTag(css)}
${body}
` return { entrypoint: 'index.html', files: [{ path: 'index.html', content: html }] } } export async function exportHTMLBundle( document: DesignDocument, options: ExportHTMLBundleOptions = {} ): Promise { const resolvedOptions: Required = { html: options.html ?? 'fragment', style: options.style ?? 'inline', assets: options.assets ?? 'inline', fonts: options.fonts ?? 'none', assetBasePath: options.assetBasePath ?? 'assets' } if (resolvedOptions.html === 'standalone') return exportStandaloneHTML(document, resolvedOptions) return { entrypoint: 'index.html', files: [ { path: 'index.html', content: serializeHTML(document, { style: resolvedOptions.style }) } ] } }