x4js 2.3.19 → 2.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -569,6 +569,472 @@ export function renderBarcodeImage( type: BarcodeType, text: string, opts: Barco
569
569
  return 'data:image/svg+xml,' + encodeURIComponent(new XMLSerializer().serializeToString(svg.getDom()))
570
570
  }
571
571
 
572
+ // :: SVG :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
573
+
574
+ /**
575
+ * the endpoint arc of SVG as Bézier curves, a quarter of turn at most each
576
+ * (SVG 1.1, appendix F.6.5)
577
+ */
578
+
579
+ function arcToCurves( x1: number, y1: number, rx: number, ry: number, phi: number, large: boolean, sweep: boolean, x2: number, y2: number ): PathSegment[] {
580
+ if (x1 === x2 && y1 === y2) {
581
+ return []
582
+ }
583
+
584
+ rx = Math.abs(rx)
585
+ ry = Math.abs(ry)
586
+ if (!rx || !ry) {
587
+ return [['l', x2, y2]]
588
+ }
589
+
590
+ const cos = Math.cos(phi * Math.PI / 180), sin = Math.sin(phi * Math.PI / 180)
591
+ const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2
592
+ const xp = cos * dx + sin * dy, yp = -sin * dx + cos * dy
593
+
594
+ // radii too small: scaled up
595
+ const lambda = (xp * xp) / (rx * rx) + (yp * yp) / (ry * ry)
596
+ if (lambda > 1) {
597
+ rx *= Math.sqrt(lambda)
598
+ ry *= Math.sqrt(lambda)
599
+ }
600
+
601
+ const num = rx * rx * ry * ry - rx * rx * yp * yp - ry * ry * xp * xp
602
+ const den = rx * rx * yp * yp + ry * ry * xp * xp
603
+ const k = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den))
604
+ const cxp = k * rx * yp / ry, cyp = -k * ry * xp / rx
605
+ const cx = cos * cxp - sin * cyp + (x1 + x2) / 2
606
+ const cy = sin * cxp + cos * cyp + (y1 + y2) / 2
607
+
608
+ const angle = ( ux: number, uy: number, vx: number, vy: number ) => {
609
+ const a = Math.acos(Math.max(-1, Math.min(1, (ux * vx + uy * vy) / (Math.hypot(ux, uy) * Math.hypot(vx, vy)))))
610
+ return (ux * vy - uy * vx < 0) ? -a : a
611
+ }
612
+
613
+ const t1 = angle(1, 0, (xp - cxp) / rx, (yp - cyp) / ry)
614
+ let dt = angle((xp - cxp) / rx, (yp - cyp) / ry, (-xp - cxp) / rx, (-yp - cyp) / ry)
615
+ if (!sweep && dt > 0) {
616
+ dt -= 2 * Math.PI
617
+ }
618
+ else if (sweep && dt < 0) {
619
+ dt += 2 * Math.PI
620
+ }
621
+
622
+ const n = Math.ceil(Math.abs(dt) / (Math.PI / 2) - 1e-9)
623
+ const step = dt / n
624
+ const kappa = 4 / 3 * Math.tan(step / 4)
625
+
626
+ // a point of the ellipse, and its derivative
627
+ const pt = ( t: number ) => [cx + rx * Math.cos(t) * cos - ry * Math.sin(t) * sin, cy + rx * Math.cos(t) * sin + ry * Math.sin(t) * cos]
628
+ const dv = ( t: number ) => [-rx * Math.sin(t) * cos - ry * Math.cos(t) * sin, -rx * Math.sin(t) * sin + ry * Math.cos(t) * cos]
629
+
630
+ const out: PathSegment[] = []
631
+ for (let i = 0; i < n; i++) {
632
+ const a = t1 + i * step, b = a + step
633
+ const [ax, ay] = pt(a), [bx, by] = pt(b)
634
+ const [adx, ady] = dv(a), [bdx, bdy] = dv(b)
635
+ const last = i === n - 1
636
+ out.push(['c', ax + kappa * adx, ay + kappa * ady, bx - kappa * bdx, by - kappa * bdy, last ? x2 : bx, last ? y2 : by])
637
+ }
638
+ return out
639
+ }
640
+
641
+ /**
642
+ * The "d" of a SVG path as absolute segments, in its own coordinates (y goes down).
643
+ * M L H V C S Q T A Z, absolute and relative; Q and T become exact cubic curves,
644
+ * A becomes Bézier curves. Another command throws: better than a drawing cut short.
645
+ */
646
+
647
+ function parseSvgPath( d: string ): PathSegment[] {
648
+ let pos = 0
649
+
650
+ const skip = () => {
651
+ while (pos < d.length && /[\s,]/.test(d[pos])) {
652
+ pos++
653
+ }
654
+ }
655
+
656
+ const number = () => {
657
+ skip()
658
+ const m = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(d.substring(pos))
659
+ if (!m) {
660
+ throw new Error(`svg path: number expected at ${pos}: ${d.substring(pos, pos + 20)}`)
661
+ }
662
+ pos += m[0].length
663
+ return parseFloat(m[0])
664
+ }
665
+
666
+ // the flags of an arc are one digit, without a separator: "a1 1 0 001 1"
667
+ const flag = () => {
668
+ skip()
669
+ const f = d[pos++]
670
+ if (f !== '0' && f !== '1') {
671
+ throw new Error(`svg path: flag expected at ${pos - 1}`)
672
+ }
673
+ return f === '1'
674
+ }
675
+
676
+ const out: PathSegment[] = []
677
+ let cx = 0, cy = 0 // current point
678
+ let sx = 0, sy = 0 // start of the subpath
679
+ let qx = 0, qy = 0 // last control point, reflected by S and T
680
+ let last = '' // last command, uppercase
681
+ let cmd = ''
682
+
683
+ const curve = ( x1: number, y1: number, x2: number, y2: number, x: number, y: number ) => {
684
+ out.push(['c', x1, y1, x2, y2, x, y])
685
+ qx = x2; qy = y2
686
+ cx = x; cy = y
687
+ }
688
+
689
+ const quad = ( x1: number, y1: number, x: number, y: number ) => {
690
+ out.push(['c', cx + 2 / 3 * (x1 - cx), cy + 2 / 3 * (y1 - cy), x + 2 / 3 * (x1 - x), y + 2 / 3 * (y1 - y), x, y])
691
+ qx = x1; qy = y1
692
+ cx = x; cy = y
693
+ }
694
+
695
+ skip()
696
+ while (pos < d.length) {
697
+ if (/[a-zA-Z]/.test(d[pos])) {
698
+ cmd = d[pos++]
699
+ }
700
+ else if (!cmd) {
701
+ throw new Error(`svg path: command expected at ${pos}`)
702
+ }
703
+
704
+ const rel = cmd === cmd.toLowerCase()
705
+ const ox = rel ? cx : 0, oy = rel ? cy : 0
706
+ const C = cmd.toUpperCase()
707
+
708
+ switch (C) {
709
+ case 'Z':
710
+ out.push(['h'])
711
+ cx = sx; cy = sy
712
+ break
713
+
714
+ case 'M':
715
+ cx = sx = number() + ox
716
+ cy = sy = number() + oy
717
+ out.push(['m', cx, cy])
718
+ cmd = rel ? 'l' : 'L' // the next pairs are lines
719
+ break
720
+
721
+ case 'L':
722
+ cx = number() + ox
723
+ cy = number() + oy
724
+ out.push(['l', cx, cy])
725
+ break
726
+
727
+ case 'H':
728
+ cx = number() + ox
729
+ out.push(['l', cx, cy])
730
+ break
731
+
732
+ case 'V':
733
+ cy = number() + oy
734
+ out.push(['l', cx, cy])
735
+ break
736
+
737
+ case 'C':
738
+ curve(number() + ox, number() + oy, number() + ox, number() + oy, number() + ox, number() + oy)
739
+ break
740
+
741
+ case 'S': {
742
+ const x1 = (last === 'C' || last === 'S') ? 2 * cx - qx : cx
743
+ const y1 = (last === 'C' || last === 'S') ? 2 * cy - qy : cy
744
+ curve(x1, y1, number() + ox, number() + oy, number() + ox, number() + oy)
745
+ break
746
+ }
747
+
748
+ case 'Q':
749
+ quad(number() + ox, number() + oy, number() + ox, number() + oy)
750
+ break
751
+
752
+ case 'T': {
753
+ const x1 = (last === 'Q' || last === 'T') ? 2 * cx - qx : cx
754
+ const y1 = (last === 'Q' || last === 'T') ? 2 * cy - qy : cy
755
+ quad(x1, y1, number() + ox, number() + oy)
756
+ break
757
+ }
758
+
759
+ case 'A': {
760
+ const rx = number(), ry = number(), phi = number()
761
+ const large = flag(), sweep = flag()
762
+ const x = number() + ox, y = number() + oy
763
+ out.push(...arcToCurves(cx, cy, rx, ry, phi, large, sweep, x, y))
764
+ cx = x; cy = y
765
+ break
766
+ }
767
+
768
+ default:
769
+ throw new Error(`svg path: command not handled: ${cmd}`)
770
+ }
771
+
772
+ last = C
773
+ skip()
774
+ }
775
+
776
+ return out
777
+ }
778
+
779
+ // a 2d affine transform: [a b c d e f], x' = a x + c y + e, y' = b x + d y + f
780
+ type Matrix = [number, number, number, number, number, number]
781
+
782
+ const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0]
783
+
784
+ function mulMatrix( m: Matrix, n: Matrix ): Matrix {
785
+ return [
786
+ m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
787
+ m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
788
+ m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5],
789
+ ]
790
+ }
791
+
792
+ function parseTransform( tr: string ): Matrix {
793
+ let m = IDENTITY
794
+ for (const [, fn, args] of tr.matchAll(/(\w+)\s*\(([^)]*)\)/g)) {
795
+ const v = args.split(/[\s,]+/).filter(s => s).map(Number)
796
+ let t: Matrix
797
+ switch (fn) {
798
+ case 'matrix': t = v.slice(0, 6) as Matrix; break
799
+ case 'translate': t = [1, 0, 0, 1, v[0], v[1] ?? 0]; break
800
+ case 'scale': t = [v[0], 0, 0, v[1] ?? v[0], 0, 0]; break
801
+ case 'rotate': {
802
+ const a = v[0] * Math.PI / 180, c = Math.cos(a), s = Math.sin(a)
803
+ const [px, py] = [v[1] ?? 0, v[2] ?? 0]
804
+ t = [c, s, -s, c, px - c * px + s * py, py - s * px - c * py]
805
+ break
806
+ }
807
+ case 'skewX': t = [1, 0, Math.tan(v[0] * Math.PI / 180), 1, 0, 0]; break
808
+ case 'skewY': t = [1, Math.tan(v[0] * Math.PI / 180), 0, 1, 0, 0]; break
809
+ default: continue
810
+ }
811
+ m = mulMatrix(m, t)
812
+ }
813
+ return m
814
+ }
815
+
816
+ function transformSegments( segs: PathSegment[], m: Matrix ): PathSegment[] {
817
+ const X = ( x: number, y: number ) => m[0] * x + m[2] * y + m[4]
818
+ const Y = ( x: number, y: number ) => m[1] * x + m[3] * y + m[5]
819
+
820
+ return segs.map( ( s ): PathSegment => {
821
+ switch (s[0]) {
822
+ case 'm': return ['m', X(s[1], s[2]), Y(s[1], s[2])]
823
+ case 'l': return ['l', X(s[1], s[2]), Y(s[1], s[2])]
824
+ case 'c': return ['c', X(s[1], s[2]), Y(s[1], s[2]), X(s[3], s[4]), Y(s[3], s[4]), X(s[5], s[6]), Y(s[5], s[6])]
825
+ default: return ['h']
826
+ }
827
+ } )
828
+ }
829
+
830
+ /**
831
+ * The text of a svg, as an Icon takes it: "var:name" (a CSS variable), "data:image/svg+xml,...",
832
+ * "data:image/svg+xml;base64,..." (a svg imported by the bundler), "<svg...". A file (".svg") cannot
833
+ * be read while a page is drawn: load it first, with svgLoader.load( )
834
+ */
835
+
836
+ function svgSource( icon: string ): string {
837
+ while (icon.startsWith('var:')) {
838
+ // "var:home" or "var:--home": the variable --home, set on <html>
839
+ let name = icon.substring(4)
840
+ if (!name.startsWith('--')) {
841
+ name = '--' + name
842
+ }
843
+
844
+ icon = document.documentElement.style.getPropertyValue(name).trim()
845
+ }
846
+
847
+ if (icon.startsWith('data:image/svg+xml;base64,')) {
848
+ return atob(icon.substring(26))
849
+ }
850
+
851
+ if (icon.startsWith('data:image/svg+xml,')) {
852
+ const body = icon.substring(19)
853
+ return body.startsWith('<') ? body : decodeURIComponent(body)
854
+ }
855
+
856
+ if (icon.trimStart().startsWith('<')) {
857
+ return icon
858
+ }
859
+
860
+ throw new Error(`icon: not a svg (a file is loaded first, by svgLoader.load): ${icon.substring(0, 40)}`)
861
+ }
862
+
863
+ export interface IconOptions {
864
+ color?: string // currentColor, and a fill that is not given; black when not given
865
+ background?: string // an opacity is mixed with it (a PDF without transparency); white when not given
866
+ }
867
+
868
+ interface SvgShape {
869
+ segs: PathSegment[] // in the coordinates of the viewBox
870
+ fill: string // null: not filled
871
+ stroke: string // null: not stroked
872
+ width: number // of the stroke, in the coordinates of the viewBox
873
+ alpha: number
874
+ evenOdd: boolean
875
+ }
876
+
877
+ const SVG_SKIP = ['defs', 'clipPath', 'mask', 'symbol', 'style', 'title', 'desc', 'metadata', 'linearGradient', 'radialGradient', 'pattern', 'marker', 'filter', 'text', 'image', 'use', 'foreignObject']
878
+
879
+ /**
880
+ * the shapes of a svg: path, rect, circle, ellipse, line, polyline, polygon, in groups,
881
+ * with their transforms. fill, stroke, stroke-width, opacity, fill-opacity, fill-rule:
882
+ * as attributes or in style="". currentColor, and a fill that is not given, are `color`
883
+ */
884
+
885
+ function svgShapes( source: string, color: string ) {
886
+ const doc = new DOMParser().parseFromString(source, 'image/svg+xml')
887
+ const root = doc.documentElement
888
+ if (root.nodeName !== 'svg') {
889
+ throw new Error('icon: not a svg')
890
+ }
891
+
892
+ let vb = (root.getAttribute('viewBox') ?? '').split(/[\s,]+/).filter(s => s).map(Number)
893
+ if (vb.length !== 4) {
894
+ vb = [0, 0, parseFloat(root.getAttribute('width')), parseFloat(root.getAttribute('height'))]
895
+ }
896
+ if (!(vb[2] > 0 && vb[3] > 0)) {
897
+ throw new Error('icon: no viewBox, nor width and height')
898
+ }
899
+
900
+ interface Paint { fill: string; stroke: string; width: number; alpha: number; fillAlpha: number; evenOdd: boolean }
901
+
902
+ const shapes: SvgShape[] = []
903
+ const n = ( el: Element, name: string, def = 0 ) => {
904
+ const v = parseFloat(el.getAttribute(name))
905
+ return isNaN(v) ? def : v
906
+ }
907
+
908
+ const visit = ( el: Element, m: Matrix, inherited: Paint ) => {
909
+ if (SVG_SKIP.includes(el.nodeName)) {
910
+ return
911
+ }
912
+
913
+ // the properties of the element: its attributes, then its style
914
+ const props: Record<string, string> = {}
915
+ for (const name of ['fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'fill-rule', 'display', 'visibility']) {
916
+ const v = el.getAttribute(name)?.trim()
917
+ if (v) {
918
+ props[name] = v
919
+ }
920
+ }
921
+ for (const decl of (el.getAttribute('style') ?? '').split(';')) {
922
+ const [k, v] = decl.split(':').map(s => s?.trim())
923
+ if (k && v) {
924
+ props[k] = v
925
+ }
926
+ }
927
+
928
+ if (props.display === 'none' || props.visibility === 'hidden') {
929
+ return
930
+ }
931
+
932
+ const paint = (v: string, cur: string) => {
933
+ if (v === undefined || v === 'inherit') {
934
+ return cur
935
+ }
936
+ if (v === 'none' || v === 'transparent') {
937
+ return null
938
+ }
939
+ return (v === 'currentColor' || v.startsWith('url(')) ? color : v
940
+ }
941
+
942
+ const p: Paint = {
943
+ fill: paint(props.fill, inherited.fill),
944
+ stroke: paint(props.stroke, inherited.stroke),
945
+ width: props['stroke-width'] !== undefined ? parseFloat(props['stroke-width']) : inherited.width,
946
+ alpha: inherited.alpha * (props.opacity !== undefined ? parseFloat(props.opacity) : 1),
947
+ fillAlpha: props['fill-opacity'] !== undefined ? parseFloat(props['fill-opacity']) : inherited.fillAlpha,
948
+ evenOdd: props['fill-rule'] !== undefined ? props['fill-rule'] === 'evenodd' : inherited.evenOdd,
949
+ }
950
+
951
+ const tr = el.getAttribute('transform')
952
+ const mm = tr ? mulMatrix(m, parseTransform(tr)) : m
953
+
954
+ let d: string = null
955
+ switch (el.nodeName) {
956
+ case 'svg':
957
+ case 'g':
958
+ case 'a':
959
+ for (const child of Array.from(el.children)) {
960
+ visit(child, mm, p)
961
+ }
962
+ return
963
+
964
+ case 'path':
965
+ d = el.getAttribute('d')
966
+ break
967
+
968
+ case 'rect': {
969
+ const x = n(el, 'x'), y = n(el, 'y'), w = n(el, 'width'), h = n(el, 'height')
970
+ let rx = n(el, 'rx', -1), ry = n(el, 'ry', -1)
971
+ rx = rx < 0 ? Math.max(ry, 0) : rx
972
+ ry = ry < 0 ? rx : ry
973
+ rx = Math.min(rx, w / 2)
974
+ ry = Math.min(ry, h / 2)
975
+ d = (rx && ry)
976
+ ? `M${x + rx},${y}H${x + w - rx}A${rx},${ry} 0 0 1 ${x + w},${y + ry}V${y + h - ry}A${rx},${ry} 0 0 1 ${x + w - rx},${y + h}H${x + rx}A${rx},${ry} 0 0 1 ${x},${y + h - ry}V${y + ry}A${rx},${ry} 0 0 1 ${x + rx},${y}Z`
977
+ : `M${x},${y}H${x + w}V${y + h}H${x}Z`
978
+ break
979
+ }
980
+
981
+ case 'circle':
982
+ case 'ellipse': {
983
+ const cx = n(el, 'cx'), cy = n(el, 'cy')
984
+ const rx = el.nodeName === 'circle' ? n(el, 'r') : n(el, 'rx')
985
+ const ry = el.nodeName === 'circle' ? rx : n(el, 'ry')
986
+ d = `M${cx - rx},${cy}A${rx},${ry} 0 1 0 ${cx + rx},${cy}A${rx},${ry} 0 1 0 ${cx - rx},${cy}Z`
987
+ break
988
+ }
989
+
990
+ case 'line':
991
+ d = `M${n(el, 'x1')},${n(el, 'y1')}L${n(el, 'x2')},${n(el, 'y2')}`
992
+ break
993
+
994
+ case 'polyline':
995
+ case 'polygon':
996
+ d = 'M' + (el.getAttribute('points') ?? '').trim() + (el.nodeName === 'polygon' ? 'Z' : '')
997
+ break
998
+ }
999
+
1000
+ if (!d) {
1001
+ return
1002
+ }
1003
+
1004
+ // the stroke width grows with the transform
1005
+ const scale = Math.sqrt(Math.abs(mm[0] * mm[3] - mm[1] * mm[2]))
1006
+
1007
+ shapes.push({
1008
+ segs: transformSegments(parseSvgPath(d), mm),
1009
+ fill: p.fill,
1010
+ stroke: p.stroke,
1011
+ width: p.width * scale,
1012
+ alpha: p.alpha * p.fillAlpha,
1013
+ evenOdd: p.evenOdd,
1014
+ })
1015
+ }
1016
+
1017
+ // by default, a svg is filled in black: here in the color asked
1018
+ visit(root, IDENTITY, { fill: color, stroke: null, width: 1, alpha: 1, fillAlpha: 1, evenOdd: false })
1019
+ return { box: vb, shapes }
1020
+ }
1021
+
1022
+ /**
1023
+ * a color with an opacity, mixed with the background: a PDF without transparency
1024
+ * (printers do not like it)
1025
+ */
1026
+
1027
+ function mixColor( color: string, background: string, alpha: number ): Color {
1028
+ const c = new Color(color)
1029
+ if (alpha >= 1) {
1030
+ return c
1031
+ }
1032
+
1033
+ const f = c.toRgb(), b = new Color(background).toRgb()
1034
+ const mix = ( x: number, y: number ) => Math.round(alpha * x + (1 - alpha) * y)
1035
+ return new Color(mix(f.red, b.red), mix(f.green, b.green), mix(f.blue, b.blue))
1036
+ }
1037
+
572
1038
  type PDFValue = null | boolean | number | string | PDFValue[] | Ref | { [key: string]: PDFValue }
573
1039
 
574
1040
  interface PDFObject {
@@ -707,6 +1173,16 @@ export interface PageContext {
707
1173
  * a Code 128: its bars on w x h. Without the quiet zone, left to the caller
708
1174
  */
709
1175
  barcode(type: BarcodeType, text: string, x: number, y: number, w: number, h: number, opts?: BarcodeOptions): void;
1176
+ /**
1177
+ * the "d" of a svg path: the origin of the svg on (x, y), its y going down;
1178
+ * scale: points by unit of the svg. fill and stroke as for path
1179
+ */
1180
+ svgPath(d: string, x: number, y: number, scale: number, fill: string, stroke?: string, lineWidth?: number): void;
1181
+ /**
1182
+ * a svg icon, as an Icon takes it (not a file: load it first, by svgLoader.load),
1183
+ * its viewBox fitted and centered in w x h, (x, y) the bottom left corner
1184
+ */
1185
+ icon(svg: string, x: number, y: number, w: number, h: number, opts?: IconOptions): void;
710
1186
  }
711
1187
 
712
1188
  export class X4PDFBuilder {
@@ -772,36 +1248,74 @@ export class X4PDFBuilder {
772
1248
  ops.push('S')
773
1249
  }
774
1250
 
775
- const path = (segments: PathSegment[], fill: string, stroke?: string, lineWidth: number = 1) => {
1251
+ const draw = (segments: PathSegment[], fill: Color, stroke: Color, lineWidth: number, evenOdd = false) => {
776
1252
  if (!fill && !stroke) {
777
1253
  return
778
1254
  }
779
1255
 
780
1256
  if (fill) {
781
- ops.push( formatColor(new Color(fill), 'rg') );
1257
+ ops.push( formatColor(fill, 'rg') );
782
1258
  }
783
1259
 
784
1260
  if (stroke) {
785
1261
  ops.push(`${fx(lineWidth,2)} w`);
786
- ops.push( formatColor(new Color(stroke), 'RG') );
1262
+ ops.push( formatColor(stroke, 'RG') );
787
1263
  }
788
1264
 
789
1265
  for (const [op, ...coords] of segments) {
790
1266
  ops.push( [...coords.map( v => fx(v,2) ), op].join(' ') );
791
1267
  }
792
1268
 
793
- // f: fill, S: stroke, B: both
794
- ops.push( fill ? (stroke ? 'B' : 'f') : 'S' );
1269
+ // f: fill, S: stroke, B: both; * for the even-odd rule
1270
+ const star = evenOdd ? '*' : ''
1271
+ ops.push( fill ? (stroke ? 'B' + star : 'f' + star) : 'S' );
1272
+ }
1273
+
1274
+ const path = (segments: PathSegment[], fill: string, stroke?: string, lineWidth: number = 1) => {
1275
+ draw(segments, fill ? new Color(fill) : null, stroke ? new Color(stroke) : null, lineWidth)
1276
+ }
1277
+
1278
+ const svgPath = (d: string, x: number, y: number, scale: number, fill: string, stroke?: string, lineWidth: number = 1) => {
1279
+ // the origin of the svg on (x, y), its y going down
1280
+ const segs = transformSegments(parseSvgPath(d), [scale, 0, 0, -scale, x, y])
1281
+ draw(segs, fill ? new Color(fill) : null, stroke ? new Color(stroke) : null, lineWidth)
1282
+ }
1283
+
1284
+ const icon = (svg: string, x: number, y: number, w: number, h: number, opts: IconOptions = {}) => {
1285
+ const { box: [bx, by, bw, bh], shapes } = svgShapes(svgSource(svg), opts.color ?? '#000000')
1286
+ const background = opts.background ?? '#ffffff'
1287
+
1288
+ // the viewBox fits in w x h, centered, its y going down
1289
+ const s = Math.min(w / bw, h / bh)
1290
+ const ox = x + (w - bw * s) / 2 - bx * s
1291
+ const oy = y + h - (h - bh * s) / 2 + by * s
1292
+ const m: Matrix = [s, 0, 0, -s, ox, oy]
1293
+
1294
+ for (const shape of shapes) {
1295
+ draw(
1296
+ transformSegments(shape.segs, m),
1297
+ shape.fill ? mixColor(shape.fill, background, shape.alpha) : null,
1298
+ shape.stroke ? mixColor(shape.stroke, background, shape.alpha) : null,
1299
+ shape.width * s,
1300
+ shape.evenOdd
1301
+ )
1302
+ }
795
1303
  }
796
1304
 
797
1305
  const image = (jpegBytes: Uint8Array, x: number, y: number, w: number, h: number) => {
1306
+ // the size is in the frame header (SOF): the segments are followed, an EXIF
1307
+ // thumbnail inside a segment is skipped
798
1308
  let imgWidth = 0, imgHeight = 0
799
- for (let i = 0; i < jpegBytes.length - 1; i++) {
800
- if (jpegBytes[i] === 0xFF && (jpegBytes[i + 1] === 0xC0 || jpegBytes[i + 1] === 0xC2)) {
1309
+ let i = 2
1310
+ while (i + 9 < jpegBytes.length && jpegBytes[i] === 0xFF) {
1311
+ const marker = jpegBytes[i + 1]
1312
+ if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
801
1313
  imgHeight = (jpegBytes[i + 5] << 8) | jpegBytes[i + 6]
802
1314
  imgWidth = (jpegBytes[i + 7] << 8) | jpegBytes[i + 8]
803
1315
  break
804
1316
  }
1317
+
1318
+ i += 2 + ((jpegBytes[i + 2] << 8) | jpegBytes[i + 3])
805
1319
  }
806
1320
 
807
1321
  const imgName = `/Im${imageCount++}`
@@ -863,7 +1377,9 @@ export class X4PDFBuilder {
863
1377
  image,
864
1378
  link,
865
1379
  path,
866
- barcode
1380
+ barcode,
1381
+ svgPath,
1382
+ icon
867
1383
  }
868
1384
 
869
1385
  let width: number
@@ -91,6 +91,11 @@ function sequenceOf( ev: KeyboardEvent ): string {
91
91
  key = ev.code[3].toLowerCase( );
92
92
  }
93
93
 
94
+ // the space bar ( " " ): written "Space" in a sequence
95
+ if( key===" " ) {
96
+ key = "space";
97
+ }
98
+
94
99
  return join( { shift: ev.shiftKey, ctrl: ev.ctrlKey, cmd: ev.metaKey, alt: ev.altKey, key } );
95
100
  }
96
101
 
@@ -158,7 +163,6 @@ const key_names: Record<string,string> = {
158
163
  arrowdown: "↓",
159
164
  escape: "Esc",
160
165
  delete: "Del",
161
- " ": "Space",
162
166
  };
163
167
 
164
168
  /**