Skip to content

Commit

Permalink
feat(cli): add option renderInSubprocess to avoid polluting global …
Browse files Browse the repository at this point in the history
…scope
  • Loading branch information
hikerpig committed Apr 1, 2024
1 parent eab28e6 commit 911062f
Show file tree
Hide file tree
Showing 11 changed files with 304 additions and 177 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-houses-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pintora/cli': patch
---

feat(cli): add option `renderInSubprocess` to avoid polluting global scope
Binary file removed packages/pintora-cli/out.png
Binary file not shown.
31 changes: 18 additions & 13 deletions packages/pintora-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,23 @@ async function handleRenderCommand(args: CliRenderArgs) {
} as Partial<PintoraConfig>)
}

const buf = await render({
code,
devicePixelRatio,
mimeType,
backgroundColor: args.backgroundColor || config.backgroundColor,
pintoraConfig,
width: args.width,
})
if (!buf) {
consola.error(`Error during generating image`)
return
try {
const buf = await render({
code,
devicePixelRatio,
mimeType,
backgroundColor: args.backgroundColor || config.backgroundColor,
pintoraConfig,
width: args.width,
renderInSubprocess: false,
})
if (!buf) {
consola.error(`Error during generating image`)
return
}
fs.writeFileSync(args.output, buf)
consola.success(`Render success, saved to ${args.output}`)
} catch (error) {
console.error(error)
}
fs.writeFileSync(args.output, buf)
consola.success(`Render success, saved to ${args.output}`)
}
2 changes: 2 additions & 0 deletions packages/pintora-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { pintoraStandalone } from '@pintora/standalone'

export { renderInCurrentProcess } from './sameprocess-render'
export { renderInSubprocess } from './subprocess-render'
export { render } from './render'

export { pintoraStandalone }
Expand Down
160 changes: 160 additions & 0 deletions packages/pintora-cli/src/render-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { RenderOptions, IRenderer } from '@pintora/renderer'
import { pintoraStandalone, PintoraConfig } from '@pintora/standalone'
import { JSDOM } from 'jsdom'
import { implForWrapper } from 'jsdom/lib/jsdom/living/generated/utils'
import { Canvas, CanvasPattern } from 'canvas'
import { SVG_MIME_TYPE, DEFAUT_BGS } from './const'
import type { CLIRenderOptions } from './type'

export type { CLIRenderOptions } from './type'

/**
* records how many globals we have patched,
* need to restore them later to prevent polluting the global environment
*/
class GlobalPatcher {
private records: any = {}
set<K extends keyof typeof globalThis>(k: K, v: any) {
const prevValue = globalThis[k]
this.records[k] = {
prevValue,
value: v,
}

globalThis[k] = v
}

restore() {
for (const k in this.records) {
if ((globalThis as any)[k] === this.records[k].value) {
;(globalThis as any)[k] = this.records[k].prevValue
}
}
}
}

function renderPrepare(opts: CLIRenderOptions) {
const { code, backgroundColor, pintoraConfig } = opts
const devicePixelRatio = opts.devicePixelRatio || 2

const dom = new JSDOM('<!DOCTYPE html><body></body>')
const document = dom.window.document
const container = document.createElement('div')
container.id = 'pintora-container'

// setup the env for renderer
const patcher = new GlobalPatcher()
patcher.set('window', dom.window)
patcher.set('document', document)
patcher.set('CanvasPattern', CanvasPattern)
;(dom.window as any).devicePixelRatio = devicePixelRatio

global.window = dom.window as any
global.document = document
;(dom.window as any).devicePixelRatio = devicePixelRatio
;(global as any).CanvasPattern = CanvasPattern

return {
container,
pintorRender(renderOpts: Pick<RenderOptions, 'renderer'>) {
let config = pintoraStandalone.getConfig<PintoraConfig>()
if (pintoraConfig) {
config = pintoraStandalone.configApi.gnernateNewConfig(pintoraConfig)
}

const containerSize = opts.width ? { width: opts.width } : undefined
if (opts.width) {
config = pintoraStandalone.configApi.gnernateNewConfig({ core: { useMaxWidth: true } })
}

return new Promise<{ renderer: IRenderer; cleanup(): void }>((resolve, reject) => {
pintoraStandalone.renderTo(code, {
container,
renderer: renderOpts.renderer || 'canvas',
containerSize,
enhanceGraphicIR(ir) {
if (!ir.bgColor) {
const themeVariables: Partial<PintoraConfig['themeConfig']['themeVariables']> =
config.themeConfig.themeVariables || {}
const newBgColor =
backgroundColor ||
themeVariables.canvasBackground ||
(themeVariables.isDark ? DEFAUT_BGS.dark : DEFAUT_BGS.light)
ir.bgColor = newBgColor
}
return ir
},
onRender(renderer) {
resolve({
renderer,
cleanup() {
patcher.restore()
},
})
},
onError(e) {
console.error('onError', e)
patcher.restore()
reject(e)
},
})
})
},
}
}

/**
* Renders the Pintora CLI options to the specified output format.
* @param opts - The CLIRenderOptions.
* @returns A promise that resolves to the rendered output.
*/
export function render(opts: CLIRenderOptions) {
const mimeType = opts.mimeType || 'image/png'

const { pintorRender } = renderPrepare(opts)

const isSvg = mimeType === SVG_MIME_TYPE
if (isSvg) {
function renderToSvg() {
return new Promise<string>((resolve, reject) => {
pintorRender({ renderer: 'svg' })
.then(({ renderer, cleanup }) => {
const rootElement = renderer.getRootElement() as SVGSVGElement
rootElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
const html = rootElement.outerHTML
cleanup()
resolve(html)
})
.catch(reject)
})
}
return renderToSvg()
} else {
function renderToImageBuffer() {
return new Promise<Buffer>((resolve, reject) => {
pintorRender({ renderer: 'canvas' })
.then(({ renderer, cleanup }) => {
setTimeout(() => {
const buf = getBuf(renderer.getRootElement() as HTMLCanvasElement)
cleanup()
resolve(buf)
}, 20)
})
.catch(reject)
})

function getBuf(canvas: HTMLCanvasElement) {
// currently jsdom only support node-canvas,
// and this is it's not-so-stable method for getting the underlying node-canvas instance
const wrapper = implForWrapper(canvas)
const nodeCanvas: Canvas = wrapper._canvas
const context = nodeCanvas.getContext('2d')
context.quality = 'best'
context.patternQuality = 'best'
const buf = nodeCanvas.toBuffer(mimeType as any)
return buf
}
}
return renderToImageBuffer()
}
}
175 changes: 11 additions & 164 deletions packages/pintora-cli/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,170 +1,17 @@
import { RenderOptions, IRenderer } from '@pintora/renderer'
import { pintoraStandalone, PintoraConfig, DeepPartial } from '@pintora/standalone'
import { JSDOM } from 'jsdom'
import { implForWrapper } from 'jsdom/lib/jsdom/living/generated/utils'
import { Canvas, CanvasPattern } from 'canvas'
import { SVG_MIME_TYPE, DEFAUT_BGS } from './const'
import type { CLIRenderOptions } from './render-impl'
import { renderInCurrentProcess } from './sameprocess-render'
import { renderInSubprocess } from './subprocess-render'
import { render as renderImpl } from './render-impl'

export type CLIRenderOptions = {
/**
* pintora DSL to render
*/
code: string
devicePixelRatio?: number | null
mimeType?: string
/**
* Assign extra background color
*/
backgroundColor?: string
pintoraConfig?: DeepPartial<PintoraConfig>
/**
* width of the output, height will be calculated according to the diagram content ratio
*/
width?: number
}

/**
* records how many globals we have patched,
* need to restore them later to prevent polluting the global environment
*/
class GlobalPatcher {
private records: any = {}
set<K extends keyof typeof globalThis>(k: K, v: any) {
const prevValue = globalThis[k]
this.records[k] = {
prevValue,
value: v,
}
const shouldEnableRenderInSubprocess = typeof jest === 'undefined'

globalThis[k] = v
export async function render(opts: CLIRenderOptions): Promise<ReturnType<typeof renderImpl>> {
if (typeof opts.renderInSubprocess === 'undefined') {
opts.renderInSubprocess = shouldEnableRenderInSubprocess
}

restore() {
for (const k in this.records) {
if ((globalThis as any)[k] === this.records[k].value) {
;(globalThis as any)[k] = this.records[k].prevValue
}
}
}
}

function renderPrepare(opts: CLIRenderOptions) {
const { code, backgroundColor, pintoraConfig } = opts
const devicePixelRatio = opts.devicePixelRatio || 2

const dom = new JSDOM('<!DOCTYPE html><body></body>')
const document = dom.window.document
const container = document.createElement('div')
container.id = 'pintora-container'

// setup the env for renderer
const patcher = new GlobalPatcher()
patcher.set('window', dom.window)
patcher.set('document', document)
patcher.set('CanvasPattern', CanvasPattern)
;(dom.window as any).devicePixelRatio = devicePixelRatio

return {
container,
pintorRender(renderOpts: Pick<RenderOptions, 'renderer'>) {
let config = pintoraStandalone.getConfig<PintoraConfig>()
if (pintoraConfig) {
config = pintoraStandalone.configApi.gnernateNewConfig(pintoraConfig)
}

const containerSize = opts.width ? { width: opts.width } : undefined
if (opts.width) {
config = pintoraStandalone.configApi.gnernateNewConfig({ core: { useMaxWidth: true } })
}

return new Promise<{ renderer: IRenderer; cleanup(): void }>((resolve, reject) => {
pintoraStandalone.renderTo(code, {
container,
renderer: renderOpts.renderer || 'canvas',
containerSize,
enhanceGraphicIR(ir) {
if (!ir.bgColor) {
const themeVariables: Partial<PintoraConfig['themeConfig']['themeVariables']> =
config.themeConfig.themeVariables || {}
const newBgColor =
backgroundColor ||
themeVariables.canvasBackground ||
(themeVariables.isDark ? DEFAUT_BGS.dark : DEFAUT_BGS.light)
ir.bgColor = newBgColor
}
return ir
},
onRender(renderer) {
resolve({
renderer,
cleanup() {
patcher.restore()
},
})
},
onError(e) {
console.error('onError', e)
patcher.restore()
reject(e)
},
})
})
},
}
}

/**
* Renders the Pintora CLI options to the specified output format.
* @param opts - The CLIRenderOptions.
* @returns A promise that resolves to the rendered output.
*/
export function render(opts: CLIRenderOptions) {
const mimeType = opts.mimeType || 'image/png'

const { pintorRender } = renderPrepare(opts)

const isSvg = mimeType === SVG_MIME_TYPE
if (isSvg) {
function renderToSvg() {
return new Promise<string>((resolve, reject) => {
pintorRender({ renderer: 'svg' })
.then(({ renderer, cleanup }) => {
const rootElement = renderer.getRootElement() as SVGSVGElement
rootElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
const html = rootElement.outerHTML
cleanup()
resolve(html)
})
.catch(reject)
})
}
return renderToSvg()
if (opts.renderInSubprocess) {
return renderInSubprocess(opts) as any
} else {
function renderToImageBuffer() {
return new Promise<Buffer>((resolve, reject) => {
pintorRender({ renderer: 'canvas' })
.then(({ renderer, cleanup }) => {
setTimeout(() => {
const buf = getBuf(renderer.getRootElement() as HTMLCanvasElement)
cleanup()
resolve(buf)
}, 20)
})
.catch(reject)
})

function getBuf(canvas: HTMLCanvasElement) {
// currently jsdom only support node-canvas,
// and this is it's not-so-stable method for getting the underlying node-canvas instance
const wrapper = implForWrapper(canvas)
const nodeCanvas: Canvas = wrapper._canvas
const context = nodeCanvas.getContext('2d')
context.quality = 'best'
context.patternQuality = 'best'
const buf = nodeCanvas.toBuffer(mimeType as any)
return buf
}
}
return renderToImageBuffer()
return renderInCurrentProcess(opts)
}
}
Loading

0 comments on commit 911062f

Please sign in to comment.