openpencil/packages/dom-css/src/html-export.ts
2026-07-05 11:01:18 +03:00

372 lines
13 KiB
TypeScript

import { CSSFontFaceRule } from '@acemir/cssom'
import { parseFragment, serialize, type DefaultTreeAdapterTypes } from 'parse5'
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<typeof value> => 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<string>()
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<string> {
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<string, WebFontFaceRequest>): 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<ExportHTMLBundleOptions>
): Promise<{ css: string; files: ExportHTMLFile[] }> {
if (options.fonts === 'none' || options.assets !== 'external') return { css: '', files: [] }
const requests = new Map<string, WebFontFaceRequest>()
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 bytesFromBase64(value: string): Uint8Array {
return Uint8Array.from(atob(value), (char) => char.charCodeAt(0))
}
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<string, string>()
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: bytesFromBase64(parts.base64) })
src.value = path
})
return { html: serialize(fragment), files }
}
function stylesheetLink(path: string): string {
return `<link rel="stylesheet" href="${path}">`
}
function styleTag(css: string): string {
return css ? `<style>${css}</style>` : ''
}
async function exportStandaloneHTML(
document: DesignDocument,
options: Required<ExportHTMLBundleOptions>
): Promise<ExportHTMLBundle> {
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 = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${stylesheetLink(cssPath)}</head><body><main data-open-pencil-html="standalone" class="op-stage">${body}</main></body></html>`
return {
entrypoint: 'index.html',
files: [
{ path: 'index.html', content: html },
{ path: cssPath, content: `${fonts.css}${css}` },
...fonts.files,
...extracted.files
]
}
}
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${styleTag(css)}</head><body><main data-open-pencil-html="standalone" class="op-stage">${body}</main></body></html>`
return { entrypoint: 'index.html', files: [{ path: 'index.html', content: html }] }
}
export async function exportHTMLBundle(
document: DesignDocument,
options: ExportHTMLBundleOptions = {}
): Promise<ExportHTMLBundle> {
const resolvedOptions: Required<ExportHTMLBundleOptions> = {
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 }) }
]
}
}