x4js 2.3.19 → 2.3.20

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.
package/aicontext.md CHANGED
@@ -1548,6 +1548,8 @@ export interface PageContext { // origin bottom left, y goes up
1548
1548
  image(jpegBytes: Uint8Array, x, y, w, h): void
1549
1549
  link(url, x, y, w, h, opts?: { underline?: string }): void
1550
1550
  barcode(type: BarcodeType, text, x, y, w, h, opts?: BarcodeOptions): void // datamatrix: square min(w,h), (x,y) bottom left; code128: bars on w x h; no quiet zone
1551
+ svgPath(d: string, x, y, scale, fill: string, stroke?: string, lineWidth?): void // (x,y): the svg origin, its y going down; M L H V C S Q T A Z
1552
+ icon(svg: string, x, y, w, h, opts?: { color?: string; background?: string }): void // as Icon takes it (not a file); viewBox fitted in w x h; opacity mixed with background
1551
1553
  }
1552
1554
  type PathSegment = ['m', x, y] | ['l', x, y] | ['c', x1, y1, x2, y2, x3, y3] | ['h']
1553
1555
  type BarcodeType = 'datamatrix' | 'code128'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.3.19",
3
+ "version": "2.3.20",
4
4
  "type": "module",
5
5
  "main": "src/x4.ts",
6
6
  "module": "src/x4.ts",
@@ -569,6 +569,466 @@ 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
+ icon = document.documentElement.style.getPropertyValue(icon.substring(4)).trim()
839
+ }
840
+
841
+ if (icon.startsWith('data:image/svg+xml;base64,')) {
842
+ return atob(icon.substring(26))
843
+ }
844
+
845
+ if (icon.startsWith('data:image/svg+xml,')) {
846
+ const body = icon.substring(19)
847
+ return body.startsWith('<') ? body : decodeURIComponent(body)
848
+ }
849
+
850
+ if (icon.trimStart().startsWith('<')) {
851
+ return icon
852
+ }
853
+
854
+ throw new Error(`icon: not a svg (a file is loaded first, by svgLoader.load): ${icon.substring(0, 40)}`)
855
+ }
856
+
857
+ export interface IconOptions {
858
+ color?: string // currentColor, and a fill that is not given; black when not given
859
+ background?: string // an opacity is mixed with it (a PDF without transparency); white when not given
860
+ }
861
+
862
+ interface SvgShape {
863
+ segs: PathSegment[] // in the coordinates of the viewBox
864
+ fill: string // null: not filled
865
+ stroke: string // null: not stroked
866
+ width: number // of the stroke, in the coordinates of the viewBox
867
+ alpha: number
868
+ evenOdd: boolean
869
+ }
870
+
871
+ const SVG_SKIP = ['defs', 'clipPath', 'mask', 'symbol', 'style', 'title', 'desc', 'metadata', 'linearGradient', 'radialGradient', 'pattern', 'marker', 'filter', 'text', 'image', 'use', 'foreignObject']
872
+
873
+ /**
874
+ * the shapes of a svg: path, rect, circle, ellipse, line, polyline, polygon, in groups,
875
+ * with their transforms. fill, stroke, stroke-width, opacity, fill-opacity, fill-rule:
876
+ * as attributes or in style="". currentColor, and a fill that is not given, are `color`
877
+ */
878
+
879
+ function svgShapes( source: string, color: string ) {
880
+ const doc = new DOMParser().parseFromString(source, 'image/svg+xml')
881
+ const root = doc.documentElement
882
+ if (root.nodeName !== 'svg') {
883
+ throw new Error('icon: not a svg')
884
+ }
885
+
886
+ let vb = (root.getAttribute('viewBox') ?? '').split(/[\s,]+/).filter(s => s).map(Number)
887
+ if (vb.length !== 4) {
888
+ vb = [0, 0, parseFloat(root.getAttribute('width')), parseFloat(root.getAttribute('height'))]
889
+ }
890
+ if (!(vb[2] > 0 && vb[3] > 0)) {
891
+ throw new Error('icon: no viewBox, nor width and height')
892
+ }
893
+
894
+ interface Paint { fill: string; stroke: string; width: number; alpha: number; fillAlpha: number; evenOdd: boolean }
895
+
896
+ const shapes: SvgShape[] = []
897
+ const n = ( el: Element, name: string, def = 0 ) => {
898
+ const v = parseFloat(el.getAttribute(name))
899
+ return isNaN(v) ? def : v
900
+ }
901
+
902
+ const visit = ( el: Element, m: Matrix, inherited: Paint ) => {
903
+ if (SVG_SKIP.includes(el.nodeName)) {
904
+ return
905
+ }
906
+
907
+ // the properties of the element: its attributes, then its style
908
+ const props: Record<string, string> = {}
909
+ for (const name of ['fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'fill-rule', 'display', 'visibility']) {
910
+ const v = el.getAttribute(name)?.trim()
911
+ if (v) {
912
+ props[name] = v
913
+ }
914
+ }
915
+ for (const decl of (el.getAttribute('style') ?? '').split(';')) {
916
+ const [k, v] = decl.split(':').map(s => s?.trim())
917
+ if (k && v) {
918
+ props[k] = v
919
+ }
920
+ }
921
+
922
+ if (props.display === 'none' || props.visibility === 'hidden') {
923
+ return
924
+ }
925
+
926
+ const paint = (v: string, cur: string) => {
927
+ if (v === undefined || v === 'inherit') {
928
+ return cur
929
+ }
930
+ if (v === 'none' || v === 'transparent') {
931
+ return null
932
+ }
933
+ return (v === 'currentColor' || v.startsWith('url(')) ? color : v
934
+ }
935
+
936
+ const p: Paint = {
937
+ fill: paint(props.fill, inherited.fill),
938
+ stroke: paint(props.stroke, inherited.stroke),
939
+ width: props['stroke-width'] !== undefined ? parseFloat(props['stroke-width']) : inherited.width,
940
+ alpha: inherited.alpha * (props.opacity !== undefined ? parseFloat(props.opacity) : 1),
941
+ fillAlpha: props['fill-opacity'] !== undefined ? parseFloat(props['fill-opacity']) : inherited.fillAlpha,
942
+ evenOdd: props['fill-rule'] !== undefined ? props['fill-rule'] === 'evenodd' : inherited.evenOdd,
943
+ }
944
+
945
+ const tr = el.getAttribute('transform')
946
+ const mm = tr ? mulMatrix(m, parseTransform(tr)) : m
947
+
948
+ let d: string = null
949
+ switch (el.nodeName) {
950
+ case 'svg':
951
+ case 'g':
952
+ case 'a':
953
+ for (const child of Array.from(el.children)) {
954
+ visit(child, mm, p)
955
+ }
956
+ return
957
+
958
+ case 'path':
959
+ d = el.getAttribute('d')
960
+ break
961
+
962
+ case 'rect': {
963
+ const x = n(el, 'x'), y = n(el, 'y'), w = n(el, 'width'), h = n(el, 'height')
964
+ let rx = n(el, 'rx', -1), ry = n(el, 'ry', -1)
965
+ rx = rx < 0 ? Math.max(ry, 0) : rx
966
+ ry = ry < 0 ? rx : ry
967
+ rx = Math.min(rx, w / 2)
968
+ ry = Math.min(ry, h / 2)
969
+ d = (rx && ry)
970
+ ? `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`
971
+ : `M${x},${y}H${x + w}V${y + h}H${x}Z`
972
+ break
973
+ }
974
+
975
+ case 'circle':
976
+ case 'ellipse': {
977
+ const cx = n(el, 'cx'), cy = n(el, 'cy')
978
+ const rx = el.nodeName === 'circle' ? n(el, 'r') : n(el, 'rx')
979
+ const ry = el.nodeName === 'circle' ? rx : n(el, 'ry')
980
+ d = `M${cx - rx},${cy}A${rx},${ry} 0 1 0 ${cx + rx},${cy}A${rx},${ry} 0 1 0 ${cx - rx},${cy}Z`
981
+ break
982
+ }
983
+
984
+ case 'line':
985
+ d = `M${n(el, 'x1')},${n(el, 'y1')}L${n(el, 'x2')},${n(el, 'y2')}`
986
+ break
987
+
988
+ case 'polyline':
989
+ case 'polygon':
990
+ d = 'M' + (el.getAttribute('points') ?? '').trim() + (el.nodeName === 'polygon' ? 'Z' : '')
991
+ break
992
+ }
993
+
994
+ if (!d) {
995
+ return
996
+ }
997
+
998
+ // the stroke width grows with the transform
999
+ const scale = Math.sqrt(Math.abs(mm[0] * mm[3] - mm[1] * mm[2]))
1000
+
1001
+ shapes.push({
1002
+ segs: transformSegments(parseSvgPath(d), mm),
1003
+ fill: p.fill,
1004
+ stroke: p.stroke,
1005
+ width: p.width * scale,
1006
+ alpha: p.alpha * p.fillAlpha,
1007
+ evenOdd: p.evenOdd,
1008
+ })
1009
+ }
1010
+
1011
+ // by default, a svg is filled in black: here in the color asked
1012
+ visit(root, IDENTITY, { fill: color, stroke: null, width: 1, alpha: 1, fillAlpha: 1, evenOdd: false })
1013
+ return { box: vb, shapes }
1014
+ }
1015
+
1016
+ /**
1017
+ * a color with an opacity, mixed with the background: a PDF without transparency
1018
+ * (printers do not like it)
1019
+ */
1020
+
1021
+ function mixColor( color: string, background: string, alpha: number ): Color {
1022
+ const c = new Color(color)
1023
+ if (alpha >= 1) {
1024
+ return c
1025
+ }
1026
+
1027
+ const f = c.toRgb(), b = new Color(background).toRgb()
1028
+ const mix = ( x: number, y: number ) => Math.round(alpha * x + (1 - alpha) * y)
1029
+ return new Color(mix(f.red, b.red), mix(f.green, b.green), mix(f.blue, b.blue))
1030
+ }
1031
+
572
1032
  type PDFValue = null | boolean | number | string | PDFValue[] | Ref | { [key: string]: PDFValue }
573
1033
 
574
1034
  interface PDFObject {
@@ -707,6 +1167,16 @@ export interface PageContext {
707
1167
  * a Code 128: its bars on w x h. Without the quiet zone, left to the caller
708
1168
  */
709
1169
  barcode(type: BarcodeType, text: string, x: number, y: number, w: number, h: number, opts?: BarcodeOptions): void;
1170
+ /**
1171
+ * the "d" of a svg path: the origin of the svg on (x, y), its y going down;
1172
+ * scale: points by unit of the svg. fill and stroke as for path
1173
+ */
1174
+ svgPath(d: string, x: number, y: number, scale: number, fill: string, stroke?: string, lineWidth?: number): void;
1175
+ /**
1176
+ * a svg icon, as an Icon takes it (not a file: load it first, by svgLoader.load),
1177
+ * its viewBox fitted and centered in w x h, (x, y) the bottom left corner
1178
+ */
1179
+ icon(svg: string, x: number, y: number, w: number, h: number, opts?: IconOptions): void;
710
1180
  }
711
1181
 
712
1182
  export class X4PDFBuilder {
@@ -772,26 +1242,58 @@ export class X4PDFBuilder {
772
1242
  ops.push('S')
773
1243
  }
774
1244
 
775
- const path = (segments: PathSegment[], fill: string, stroke?: string, lineWidth: number = 1) => {
1245
+ const draw = (segments: PathSegment[], fill: Color, stroke: Color, lineWidth: number, evenOdd = false) => {
776
1246
  if (!fill && !stroke) {
777
1247
  return
778
1248
  }
779
1249
 
780
1250
  if (fill) {
781
- ops.push( formatColor(new Color(fill), 'rg') );
1251
+ ops.push( formatColor(fill, 'rg') );
782
1252
  }
783
1253
 
784
1254
  if (stroke) {
785
1255
  ops.push(`${fx(lineWidth,2)} w`);
786
- ops.push( formatColor(new Color(stroke), 'RG') );
1256
+ ops.push( formatColor(stroke, 'RG') );
787
1257
  }
788
1258
 
789
1259
  for (const [op, ...coords] of segments) {
790
1260
  ops.push( [...coords.map( v => fx(v,2) ), op].join(' ') );
791
1261
  }
792
1262
 
793
- // f: fill, S: stroke, B: both
794
- ops.push( fill ? (stroke ? 'B' : 'f') : 'S' );
1263
+ // f: fill, S: stroke, B: both; * for the even-odd rule
1264
+ const star = evenOdd ? '*' : ''
1265
+ ops.push( fill ? (stroke ? 'B' + star : 'f' + star) : 'S' );
1266
+ }
1267
+
1268
+ const path = (segments: PathSegment[], fill: string, stroke?: string, lineWidth: number = 1) => {
1269
+ draw(segments, fill ? new Color(fill) : null, stroke ? new Color(stroke) : null, lineWidth)
1270
+ }
1271
+
1272
+ const svgPath = (d: string, x: number, y: number, scale: number, fill: string, stroke?: string, lineWidth: number = 1) => {
1273
+ // the origin of the svg on (x, y), its y going down
1274
+ const segs = transformSegments(parseSvgPath(d), [scale, 0, 0, -scale, x, y])
1275
+ draw(segs, fill ? new Color(fill) : null, stroke ? new Color(stroke) : null, lineWidth)
1276
+ }
1277
+
1278
+ const icon = (svg: string, x: number, y: number, w: number, h: number, opts: IconOptions = {}) => {
1279
+ const { box: [bx, by, bw, bh], shapes } = svgShapes(svgSource(svg), opts.color ?? '#000000')
1280
+ const background = opts.background ?? '#ffffff'
1281
+
1282
+ // the viewBox fits in w x h, centered, its y going down
1283
+ const s = Math.min(w / bw, h / bh)
1284
+ const ox = x + (w - bw * s) / 2 - bx * s
1285
+ const oy = y + h - (h - bh * s) / 2 + by * s
1286
+ const m: Matrix = [s, 0, 0, -s, ox, oy]
1287
+
1288
+ for (const shape of shapes) {
1289
+ draw(
1290
+ transformSegments(shape.segs, m),
1291
+ shape.fill ? mixColor(shape.fill, background, shape.alpha) : null,
1292
+ shape.stroke ? mixColor(shape.stroke, background, shape.alpha) : null,
1293
+ shape.width * s,
1294
+ shape.evenOdd
1295
+ )
1296
+ }
795
1297
  }
796
1298
 
797
1299
  const image = (jpegBytes: Uint8Array, x: number, y: number, w: number, h: number) => {
@@ -863,7 +1365,9 @@ export class X4PDFBuilder {
863
1365
  image,
864
1366
  link,
865
1367
  path,
866
- barcode
1368
+ barcode,
1369
+ svgPath,
1370
+ icon
867
1371
  }
868
1372
 
869
1373
  let width: number