Skip to content

Commit

Permalink
feat wintercg runtime (#233)
Browse files Browse the repository at this point in the history
  • Loading branch information
hikerpig authored Jan 2, 2024
1 parent 4d419a7 commit 0a27a38
Show file tree
Hide file tree
Showing 26 changed files with 1,245 additions and 74 deletions.
8 changes: 8 additions & 0 deletions .changeset/fair-zoos-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@pintora/core': patch
'@pintora/renderer': patch
'@pintora/standalone': patch
'@pintora/target-wintercg': patch
---

Be able to inject text-metric calculator in case there is no Canvas impl in the environment.
4 changes: 2 additions & 2 deletions packages/pintora-core/src/util/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export * from './util'
export * from './matrix'
export * from './geometry'
export { calculateTextDimensions } from './text-metric'
export type { IFont } from './text-metric'
export { calculateTextDimensions, textMetrics } from './text-metric'
export type { IFont, ITextMetricCalculator } from './text-metric'
export { encodeForUrl, decodeCodeInUrl } from './encode'
export { makeMark } from './mark'
export { parseColor, tinycolor } from './color'
Expand Down
111 changes: 74 additions & 37 deletions packages/pintora-core/src/util/text-metric.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,89 @@
import type { TSize } from './geometry'
export interface IFont {
fontFamily?: string
fontSize: number
fontWeight?: any
}

export function calculateTextDimensions(text: string, font?: IFont) {
const lines = text.split('\n')
let width = 0
let height = 0
const fontSize = font?.fontSize || 14
lines.forEach((line, i) => {
const lineMetric = getLineMetric(line, font)
// console.log('line metric', line, lineMetric)
const w = lineMetric.width
width = Math.max(w, width)
// svg renderer antv/g currently adds tspan dy with '1em', which matches fontSize
// so we will calculate height with similar method
// TODO: but it has some differences with canvas
let lineHeight = fontSize
if (i === 0) {
if ('actualBoundingBoxDescent' in lineMetric) {
lineHeight = lineMetric.actualBoundingBoxAscent + lineMetric.actualBoundingBoxDescent
export interface ITextMetricCalculator {
name?: string
calculateTextDimensions(text: string, font?: IFont): TSize
}

export type TTextMetrics = Pick<TextMetrics, 'actualBoundingBoxAscent' | 'actualBoundingBoxDescent' | 'width'>

/**
* Use canvas Context2D `measureText()` method to calculate text metrics.
*/
class CanvasTextMetricCalculator implements ITextMetricCalculator {
name = 'CanvasTextMetricCalculator'
ctx: CanvasRenderingContext2D | undefined = undefined

calculateTextDimensions(text: string, font?: IFont) {
const lines = text.split('\n')
let width = 0
let height = 0
const fontSize = font?.fontSize || 14
lines.forEach((line, i) => {
const lineMetric = this.getLineMetric(line, font)
// console.log('line metric', line, lineMetric)
const w = lineMetric.width
width = Math.max(w, width)
// svg renderer antv/g currently adds tspan dy with '1em', which matches fontSize
// so we will calculate height with similar method
// TODO: but it has some differences with canvas
let lineHeight = fontSize
if (i === 0) {
if ('actualBoundingBoxDescent' in lineMetric) {
lineHeight = lineMetric.actualBoundingBoxAscent + lineMetric.actualBoundingBoxDescent
}
}
height += lineHeight
})
// console.log('calculateTextDimensions:', text, width, height)
return {
width,
height,
}
}

getLineMetric(text: string, font?: IFont) {
const fontSize = font?.fontSize || 14
const fontFamily = font?.fontFamily || 'sans-serif'
const ctx = this.getCanvasContext()
ctx.font = `${fontSize}px ${fontFamily}`
return ctx.measureText(text)
}

/** A helper canvas context 2D for measuring text */
getCanvasContext = () => {
if (!this.ctx) {
const canvas = document.createElement('canvas')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.ctx = canvas.getContext('2d')!
}
height += lineHeight
})
// console.log('calculateTextDimensions', text, width, height)
return {
width,
height,
return this.ctx
}
}

let ctx: CanvasRenderingContext2D
/** A helper canvas context 2D for measuring text */
const getCanvasContext = () => {
if (!ctx) {
const canvas = document.createElement('canvas')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ctx = canvas.getContext('2d')!
const canvasTextCalculator = new CanvasTextMetricCalculator()

/**
* A bridge for text metric calculator, can be set to use different implementation in different environment.
* By default it uses {@link CanvasTextMetricCalculator}.
*/
class TextMetricBridge implements ITextMetricCalculator {
protected calculator: ITextMetricCalculator = canvasTextCalculator
setImpl(calculator: ITextMetricCalculator) {
this.calculator = calculator
}
calculateTextDimensions(text: string, font?: IFont | undefined) {
return this.calculator.calculateTextDimensions(text, font)
}
return ctx
}

function getLineMetric(text: string, font?: IFont) {
const fontSize = font?.fontSize || 14
const fontFamily = font?.fontFamily || 'sans-serif'
const ctx = getCanvasContext()
ctx.font = `${fontSize}px ${fontFamily}`
return ctx.measureText(text)
export const textMetrics = new TextMetricBridge()

export function calculateTextDimensions(text: string, font?: IFont) {
return textMetrics.calculateTextDimensions(text, font)
}
3 changes: 2 additions & 1 deletion packages/pintora-renderer/src/renderers/SvgRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export class SvgRenderer extends BaseRenderer {

if (mark.class) {
const el = shape.get('el')
if (el) {
// TODO: some js dom implementation does not have classList
if (el && el.classList) {
mark.class.split(' ').forEach(cls => {
if (cls) el.classList.add(cls)
})
Expand Down
8 changes: 6 additions & 2 deletions packages/pintora-standalone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@
"main": "./lib/pintora-standalone.umd.js",
"exports": {
".": {
"import": "./lib/pintora-standalone.esm.js",
"import": {
"types": "./types/index.d.ts",
"default": "./lib/pintora-standalone.esm.js"
},
"require": "./lib/pintora-standalone.umd.js"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"directories": {
Expand Down
1 change: 1 addition & 0 deletions packages/pintora-target-wintercg/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
4 changes: 4 additions & 0 deletions packages/pintora-target-wintercg/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Stil at a very early stage.

Bundle a big JS including pintora and its dependencies and some Node.js module polyfills, so that it can run inside a WinterCG or other lightweight JS runtime.

1 change: 1 addition & 0 deletions packages/pintora-target-wintercg/aliases/canvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = {}
8 changes: 8 additions & 0 deletions packages/pintora-target-wintercg/aliases/url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const URL = require('url')

module.exports = {
...URL,
fileURLToPath(s) {
return s || ''
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as path from 'path'
import { Plugin } from 'esbuild'

const nodeModulesDir = path.resolve(__dirname, '../node_modules')

export const ESBuildNodePolyfillsPlugin: Plugin = {
name: 'ESBuildNodePolyfillsPlugin',
setup(build) {
const nodeGlobalsToBeIgnored = /^((tls)|(assert)|(fs)|(net))$/
build.onResolve({ filter: nodeGlobalsToBeIgnored }, args => {
return { path: args.path, namespace: 'do-nothing' }
})

// build.onResolve({ filter: /node:path/ }, args => {
// return { namespace: 'path-browserify' }
// })

// Resolve Stream Modules
build.onResolve(
{
filter: /(_stream_duplex)|(_stream_passthrough)|(_stream_readable)|(_stream_transform)|(_stream_writable)/,
},
args => {
const pPrefix = [nodeModulesDir, 'readable-stream', 'lib']
let p
if (args.path.includes('_stream_duplex')) p = path.join(...pPrefix, '_stream_duplex.js')
if (args.path.includes('_stream_passthrough')) p = path.join(...pPrefix, '_stream_passthrough.js')
if (args.path.includes('_stream_readable')) p = path.join(...pPrefix, '_stream_readable.js')
if (args.path.includes('_stream_transform')) p = path.join(...pPrefix, '_stream_transform.js')
if (args.path.includes('_stream_writable')) p = path.join(...pPrefix, '_stream_writable.js')
return { path: p }
},
)

// // Special Case for the "SAP Cloud SDK for JavaScript"
// build.onResolve({ filter: /.*\/internal\/streams\/stream/ }, (args) => ({
// path: path.join(__dirname, "../", "stream-browserify", "index.js"),
// }));

// Resolve other packages
build.onResolve(
{
filter:
/^((buffer)|(crypto)|(http)|(https)|(os)|(path)|(node:path)|(stream)|(zlib)|(url)|(events)|(process)|(util))$/,
},
args => {
const pPrefix = [nodeModulesDir]
let p
switch (args.path) {
case 'buffer':
p = path.join(...pPrefix, 'buffer', 'index.js')
break
case 'crypto':
p = path.join(...pPrefix, 'crypto-browserify', 'index.js')
break
case 'http':
p = path.join(...pPrefix, 'stream-http', 'index.js')
break
case 'https':
p = path.join(...pPrefix, 'https-browserify', 'index.js')
break
case 'os':
p = path.join(...pPrefix, 'os-browserify', 'browser.js')
break
case 'node:path':
p = path.join(...pPrefix, 'path-browserify', 'index.js')
break
case 'path':
p = path.join(...pPrefix, 'path-browserify', 'index.js')
break
// case "stream":
// p = path.join(...pPrefix, "stream-browserify", "index.js");
// break;
case 'zlib':
p = path.join(...pPrefix, 'browserify-zlib', 'lib', 'index.js')
break
case 'events':
p = path.join(...pPrefix, 'events', 'events.js')
break
case 'process':
p = path.join(...pPrefix, 'process', 'browser.js')
break
case 'util':
p = path.join(...pPrefix, 'util', 'util.js')
break
}
if (p) {
return { path: p, external: false }
}
},
)

// Do nothing on specified fields
build.onLoad({ filter: /.*/, namespace: 'do-nothing' }, args => ({
contents: 'export default false;',
}))
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as fs from 'fs'
import { Plugin } from 'esbuild'

const RUNTIME_CODE_NS = 'pintora-runtime-code'

export function makeESBuildNodePolyfillsPlugin(opts: { runtimeLibPath: string }) {
return {
name: 'ESBuildNodePolyfillsPlugin',
setup(build) {
build.onResolve({ filter: /virtual:pintora/ }, args => {
return { namespace: RUNTIME_CODE_NS, path: opts.runtimeLibPath }
})
build.onLoad({ filter: /.*/, namespace: RUNTIME_CODE_NS }, args => {
const contents = fs.readFileSync(opts.runtimeLibPath)
return {
contents,
}
})
},
} as Plugin
}
53 changes: 53 additions & 0 deletions packages/pintora-target-wintercg/build/esbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as path from 'path'
import * as fs from 'fs'
import { build, BuildOptions } from 'esbuild'
import { ESBuildNodePolyfillsPlugin } from './ESBuildNodePolyfillsPlugin'

const packageDir = path.resolve(__dirname, '..')
const aliasDir = path.resolve(__dirname, '../aliases')

const runtimeOutFilePath = path.join(packageDir, 'dist/runtime.js')

const options: BuildOptions = {
entryPoints: ['runtime/index.ts'],
bundle: true,
outfile: runtimeOutFilePath,
format: 'iife',
globalName: 'pintoraTarget',
// sourcemap: 'external',
treeShaking: true,
alias: {
canvas: path.join(aliasDir, 'canvas.js'),
fs: path.join(aliasDir, 'canvas.js'),
'node:url': path.join(aliasDir, 'url.js'),
},
plugins: [ESBuildNodePolyfillsPlugin],
loader: {
'.ttf': 'binary',
},
write: true,
}

build(options).then(afterLibEsbuild)

async function afterLibEsbuild() {
console.log('afterLibEsbuild, generate platform code')
const plugBuild = await build({
entryPoints: ['src/platforms/edge-handler.ts'],
bundle: true,
format: 'iife',
sourcemap: false,
write: false,
})
const runtimeLibCode = fs.readFileSync(runtimeOutFilePath, 'utf-8').toString()
const outdir = path.join(packageDir, 'dist/platforms')
if (!fs.existsSync(outdir)) {
fs.mkdirSync(outdir)
}
const handlerCode = `
${runtimeLibCode}
// separation
${plugBuild.outputFiles[0].text}
`
fs.writeFileSync(path.join(packageDir, 'dist/platforms/edge-handler.js'), handlerCode)
}
8 changes: 8 additions & 0 deletions packages/pintora-target-wintercg/build/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node"]
},
"include": ["."]
}
Binary file not shown.
Loading

0 comments on commit 0a27a38

Please sign in to comment.