[fix] text to svg (#936)

* rewrite export text logic

* Update tests

* Update getTextSvgElement.ts

* Update getTextSvgElement.ts

* improve line breaking

* labels + arrows

* small offset for padding

* Fix string bug
This commit is contained in:
Steve Ruiz 2022-08-28 07:07:07 +01:00 committed by GitHub
parent 4285965fab
commit 11c3d1ba27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 262 additions and 66 deletions

View file

@ -2073,8 +2073,8 @@ export class TldrawApp extends StateManager<TDSnapshot> {
)
// Clean up the SVG by removing any hidden elements
svg.setAttribute('width', commonBounds.width.toString())
svg.setAttribute('height', commonBounds.height.toString())
svg.setAttribute('width', (commonBounds.width + SVG_EXPORT_PADDING * 2).toString())
svg.setAttribute('height', (commonBounds.height + SVG_EXPORT_PADDING * 2).toString())
// Set export background
const exportBackground: TDExportBackground = this.settings.exportBackground

View file

@ -966,8 +966,8 @@ Array [
]
`;
exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" style=\\"background-color: transparent;\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs><g/></svg>"`;
exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"232\\" height=\\"232\\" style=\\"background-color: transparent;\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs><g/></svg>"`;
exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\" style=\\"background-color: transparent;\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs></svg>"`;
exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 236.74 236.74\\" width=\\"236.74\\" height=\\"236.74\\" style=\\"background-color: transparent;\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs></svg>"`;
exports[`TldrawTestApp When copying to SVG Respects child index: copied svg with reordered elements 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" style=\\"background-color: transparent;\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs></svg>"`;
exports[`TldrawTestApp When copying to SVG Respects child index: copied svg with reordered elements 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"232\\" height=\\"232\\" style=\\"background-color: transparent;\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&amp;family=Source+Code+Pro&amp;family=Source+Sans+Pro&amp;family=Crimson+Pro&amp;display=block');</style></defs></svg>"`;

View file

@ -12,12 +12,23 @@ import {
LabelMask,
TextLabel,
defaultStyle,
getFontFace,
getFontSize,
getFontStyle,
getShapeStyle,
getTextLabelSize,
getTextSvgElement,
} from '~state/shapes/shared'
import { styled } from '~styles'
import { ArrowShape, DashStyle, Decoration, TDMeta, TDShapeType, TransformInfo } from '~types'
import {
AlignStyle,
ArrowShape,
DashStyle,
Decoration,
TDMeta,
TDShapeType,
TransformInfo,
} from '~types'
import {
getArcLength,
getArcPoints,
@ -514,6 +525,64 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
return nextShape
}
getSvgElement = (shape: ArrowShape, isDarkMode: boolean): SVGElement | void => {
const elm = document.getElementById(shape.id + '_svg')?.cloneNode(true) as SVGElement
if (!elm) return // possibly in test mode
const hasLabel = shape.label?.trim()?.length ?? 0 > 0
if (hasLabel) {
const s = shape as ArrowShape
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const font = getFontStyle(shape.style)
const labelSize = getTextLabelSize(shape.label!, font)
const fontSize = getFontSize(shape.style.size, shape.style.font) * (shape.style.scale ?? 1)
const fontFamily = getFontFace(shape.style.font).slice(1, -1)
const labelElm = getTextSvgElement(
s.label!,
fontSize,
fontFamily,
AlignStyle.Start,
labelSize[0],
false
)
let dist: number
const { start, bend, end } = shape.handles
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
if (isStraightLine) {
dist = Vec.dist(start.point, end.point)
} else {
const circle = getCtp(start.point, bend.point, end.point)
const center = circle.slice(0, 2)
const radius = circle[2]
const length = getArcLength(center, radius, start.point, end.point)
dist = Math.abs(length)
}
const scale = Math.max(
0.5,
Math.min(1, Math.max(dist / (labelSize[1] + 128), dist / (labelSize[0] + 128)))
)
const bounds = this.getBounds(shape)
const offset = Vec.sub(shape.handles.bend.point, [bounds.width / 2, bounds.height / 2])
const x = bounds.width / 2 - (labelSize[0] / 2) * scale + offset[0]
const y = bounds.height / 2 - (labelSize[1] / 2) * scale + offset[1]
labelElm.setAttribute('transform', `translate(${x}, ${y})`)
labelElm.setAttribute('fill', getShapeStyle(shape.style, isDarkMode).stroke)
labelElm.setAttribute('transform-origin', 'center center')
g.setAttribute('text-align', 'center')
g.setAttribute('text-anchor', 'middle')
g.appendChild(elm)
g.appendChild(labelElm)
return g
}
return elm
}
}
const FullWrapper = styled('div', { width: '100%', height: '100%' })

View file

@ -2,13 +2,15 @@ import { HTMLContainer, TLBounds, Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import * as React from 'react'
import { stopPropagation } from '~components/stopPropagation'
import { GHOSTED_OPACITY } from '~constants'
import { GHOSTED_OPACITY, LETTER_SPACING } from '~constants'
import { TLDR } from '~state/TLDR'
import { TDShapeUtil } from '~state/shapes/TDShapeUtil'
import {
TextAreaUtils,
defaultTextStyle,
getBoundsRectangle,
getFontFace,
getStickyFontSize,
getStickyFontStyle,
getStickyShapeStyle,
getTextSvgElement,
@ -286,9 +288,22 @@ export class StickyUtil extends TDShapeUtil<T, E> {
getSvgElement = (shape: T, isDarkMode: boolean): SVGElement | void => {
const bounds = this.getBounds(shape)
const textBounds = Utils.expandBounds(bounds, -PADDING)
const textElm = getTextSvgElement(shape.text, shape.style, textBounds)
const style = getStickyShapeStyle(shape.style, isDarkMode)
const fontSize = getStickyFontSize(shape.style.size) * (shape.style.scale ?? 1)
const fontFamily = getFontFace(shape.style.font).slice(1, -1)
const textAlign = shape.style.textAlign ?? AlignStyle.Start
const textElm = getTextSvgElement(
shape.text,
fontSize,
fontFamily,
textAlign,
bounds.width - PADDING * 2,
true
)
textElm.setAttribute('fill', style.color)
textElm.setAttribute('transform', `translate(${PADDING}, ${PADDING})`)
@ -345,6 +360,7 @@ const StyledStickyContainer = styled('div', {
const commonTextWrapping = {
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
letterSpacing: LETTER_SPACING,
}
const StyledText = styled('div', {

View file

@ -9,7 +9,7 @@ import { Vec } from '@tldraw/vec'
import * as React from 'react'
import { BINDING_DISTANCE } from '~constants'
import { AlignStyle, ShapesWithProp, TDBinding, TDMeta, TDShape, TransformInfo } from '~types'
import { getFontStyle, getShapeStyle } from './shared'
import { getFontFace, getFontSize, getFontStyle, getShapeStyle } from './shared'
import { getTextLabelSize } from './shared/getTextSize'
import { getTextSvgElement } from './shared/getTextSvgElement'
@ -178,17 +178,33 @@ export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> ex
getSvgElement = (shape: T, isDarkMode: boolean): SVGElement | void => {
const elm = document.getElementById(shape.id + '_svg')?.cloneNode(true) as SVGElement
if (!elm) return // possibly in test mode
if ('label' in shape && (shape as any).label) {
const hasLabel = shape.label?.trim()?.length ?? 0 > 0
if (hasLabel) {
const s = shape as TDShape & { label: string }
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const bounds = this.getBounds(shape)
const font = getFontStyle(shape.style)
const labelSize = getTextLabelSize(shape.label!, font)
const fontSize = getFontSize(shape.style.size, shape.style.font) * (shape.style.scale ?? 1)
const fontFamily = getFontFace(shape.style.font).slice(1, -1)
const labelElm = getTextSvgElement(
s['label'],
{ ...shape.style, textAlign: AlignStyle.Start },
bounds
fontSize,
fontFamily,
AlignStyle.Start,
labelSize[0],
false
)
const bounds = this.getBounds(shape)
labelElm.setAttribute(
'transform',
`translate(${bounds.width / 2 - labelSize[0] / 2}, ${bounds.height / 2 - labelSize[1] / 2})`
)
labelElm.setAttribute('fill', getShapeStyle(shape.style, isDarkMode).stroke)
labelElm.setAttribute('transform-origin', 'top left')
labelElm.setAttribute('transform-origin', 'center center')
g.setAttribute('text-align', 'center')
g.setAttribute('text-anchor', 'middle')
g.appendChild(elm)

View file

@ -8,6 +8,8 @@ import { TDShapeUtil } from '~state/shapes/TDShapeUtil'
import {
TextAreaUtils,
defaultTextStyle,
getFontFace,
getFontSize,
getFontStyle,
getShapeStyle,
getTextAlign,
@ -389,10 +391,23 @@ export class TextUtil extends TDShapeUtil<T, E> {
getSvgElement = (shape: T, isDarkMode: boolean): SVGElement | void => {
const bounds = this.getBounds(shape)
const style = getShapeStyle(shape.style, isDarkMode)
const elm = getTextSvgElement(shape.text, shape.style, bounds)
elm.setAttribute('fill', style.stroke)
return elm
const fontSize = getFontSize(shape.style.size, shape.style.font) * (shape.style.scale ?? 1)
const fontFamily = getFontFace(shape.style.font).slice(1, -1)
const textAlign = shape.style.textAlign ?? AlignStyle.Start
const textElm = getTextSvgElement(
shape.text,
fontSize,
fontFamily,
textAlign,
bounds.width,
false
)
textElm.setAttribute('fill', style.stroke)
return textElm
}
}

View file

@ -211,6 +211,7 @@ const TextWrapper = styled('div', {
const commonTextWrapping = {
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
letterSpacing: LETTER_SPACING,
}
const InnerWrapper = styled('div', {
@ -220,7 +221,6 @@ const InnerWrapper = styled('div', {
minHeight: 1,
minWidth: 1,
lineHeight: 1,
letterSpacing: LETTER_SPACING,
outline: 0,
fontWeight: '500',
textAlign: 'center',
@ -265,7 +265,6 @@ const TextArea = styled('textarea', {
minHeight: 'inherit',
minWidth: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit',
outline: 0,
fontWeight: 'inherit',
overflow: 'hidden',

View file

@ -1,4 +1,5 @@
import { LETTER_SPACING } from '~constants'
import { FontStyle } from '~types'
let melm: any

View file

@ -1,66 +1,146 @@
import type { TLBounds } from '@tldraw/core'
import { LINE_HEIGHT } from '~constants'
import { TLBounds } from '@tldraw/core'
import { LETTER_SPACING, LINE_HEIGHT } from '~constants'
import { AlignStyle, ShapeStyles } from '~types'
import { getTextAlign } from './getTextAlign'
import { getTextLabelSize } from './getTextSize'
import { getFontFace, getFontSize, getFontStyle } from './shape-styles'
export function getTextSvgElement(text: string, style: ShapeStyles, bounds: TLBounds) {
const fontSize = getFontSize(style.size, style.font)
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const scale = style.scale ?? 1
// https://drafts.csswg.org/css-text/#word-separator
// split on any of these characters
const wordSeparator = new RegExp(
`${[0x0020, 0x00a0, 0x1361, 0x10100, 0x10101, 0x1039, 0x1091]
.map((c) => String.fromCodePoint(c))
.join('|')}`
)
const font = getFontStyle(style)
const [, height] = getTextLabelSize(text, font)
export function getTextSvgElement(
text: string,
fontSize: number,
fontFamily: string,
textAlign: AlignStyle,
width: number,
wrap = false
) {
const fontWeight = 'normal'
const lineHeight = 1
const letterSpacingPct = LETTER_SPACING
const textLines = text.split('\n').map((line, i) => {
const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text')
textElm.textContent = line
// Collect lines
textElm.setAttribute('y', fontSize * (0.5 + i * LINE_HEIGHT) + '')
textElm.setAttribute('letter-spacing', fontSize * -0.03 + '')
textElm.setAttribute('font-size', fontSize + 'px')
textElm.setAttribute('font-family', getFontFace(style.font).slice(1, -1))
textElm.setAttribute('text-align', getTextAlign(style.textAlign))
textElm.setAttribute('text-align', getTextAlign(style.textAlign))
textElm.setAttribute('alignment-baseline', 'central')
const [width] = getTextLabelSize(line, font)
console.log(font, scale, width, bounds.width)
textElm.setAttribute(
'transform',
`translate(${(bounds.width - width) / 2}, ${(bounds.height - height * scale) / 2})`
)
if (style.scale !== 1) {
textElm.setAttribute('transform', `scale(${style.scale})`)
}
g.appendChild(textElm)
return textElm
const lines = breakText({
text,
wrap,
width,
fontSize,
fontWeight,
fontFamily,
fontStyle: 'normal',
textAlign: 'left',
letterSpacing: LETTER_SPACING,
lineHeight: 1,
})
switch (style.textAlign) {
const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text')
textElm.setAttribute('font-size', fontSize + 'px')
textElm.setAttribute('font-family', fontFamily)
textElm.setAttribute('font-weight', fontWeight)
textElm.setAttribute('line-height', lineHeight * fontSize + 'px')
textElm.setAttribute('letter-spacing', letterSpacingPct)
textElm.setAttribute('text-align', textAlign ?? 'left')
textElm.setAttribute('dominant-baseline', 'mathematical')
textElm.setAttribute('alignment-baseline', 'mathematical')
const textLines = lines.map((line, i) => {
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
tspan.textContent = line + '\n'
tspan.setAttribute('y', lineHeight * fontSize * (i + 0.5) + 'px')
textElm.appendChild(tspan)
return tspan
})
switch (textAlign) {
case AlignStyle.Middle: {
g.setAttribute('text-align', 'center')
g.setAttribute('text-anchor', 'middle')
textLines.forEach((textElm) => {
textElm.setAttribute('x', bounds.width / 2 / scale + '')
})
textElm.setAttribute('text-align', 'center')
textElm.setAttribute('text-anchor', 'middle')
textLines.forEach((textElm) => textElm.setAttribute('x', 4 + width / 2 + ''))
break
}
case AlignStyle.End: {
g.setAttribute('text-align', 'right')
g.setAttribute('text-anchor', 'end')
textLines.forEach((textElm) => textElm.setAttribute('x', bounds.width / scale + ''))
textElm.setAttribute('text-align', 'right')
textElm.setAttribute('text-anchor', 'end')
textLines.forEach((textElm) => textElm.setAttribute('x', 4 + width + ''))
break
}
case AlignStyle.Start: {
g.setAttribute('text-align', 'left')
g.setAttribute('text-anchor', 'start')
default: {
textElm.setAttribute('text-align', 'left')
textElm.setAttribute('text-anchor', 'start')
textLines.forEach((textElm) => textElm.setAttribute('x', '4'))
}
}
return g
return textElm
}
function breakText(opts: {
text: string
wrap: boolean
width: number
fontSize: number
fontWeight: string
fontFamily: string
fontStyle: string
lineHeight: number
letterSpacing: string
textAlign: string
}): string[] {
const textElm = document.createElement('div')
textElm.style.setProperty('position', 'absolute')
textElm.style.setProperty('top', '-9999px')
textElm.style.setProperty('left', '-9999px')
textElm.style.setProperty('width', opts.width + 'px')
textElm.style.setProperty('height', 'min-content')
textElm.style.setProperty('font-size', opts.fontSize + 'px')
textElm.style.setProperty('font-family', opts.fontFamily)
textElm.style.setProperty('font-weight', opts.fontWeight)
textElm.style.setProperty('line-height', opts.lineHeight * opts.fontSize + 'px')
textElm.style.setProperty('letter-spacing', opts.letterSpacing)
textElm.style.setProperty('text-align', opts.textAlign)
document.body.appendChild(textElm)
// Collect lines
// Split the text into words
const words = opts.text
.split(wordSeparator)
.flatMap((word) => word.replace('\n', ' \n'))
.join(' ')
.split(' ')
// Iterate through the words looking for either line breaks, or
// when the measured line exceeds the width of the container (minus
// its padding); at either point, create a new line.
textElm.innerText = words[0]
let prevHeight = textElm.offsetHeight
let currentLine = [words[0]]
const lines: string[][] = [currentLine]
for (let i = 1; i < words.length; i++) {
const word = words[i]
textElm.innerText += ' ' + word
const newHeight = textElm.offsetHeight
if (newHeight > prevHeight) {
prevHeight = newHeight
currentLine = []
lines.push(currentLine)
}
// Push the current word to the current line
currentLine.push(word)
}
textElm.remove()
return lines.map((line) => line.join(' '))
}