mirror of
https://github.com/penpot/penpot.git
synced 2025-07-17 00:25:53 +02:00
Merge pull request #6754 from penpot/azazeln28-issue-11401-fix-wrong-aspect-ratio
🐛 Fix image aspect ratio rendering on oriented images
This commit is contained in:
commit
bdc10ac173
8 changed files with 875 additions and 90 deletions
BIN
frontend/playwright/data/render-wasm/assets/landscape.jpg
Normal file
BIN
frontend/playwright/data/render-wasm/assets/landscape.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 344 KiB |
|
@ -0,0 +1,779 @@
|
||||||
|
{
|
||||||
|
"~:features": {
|
||||||
|
"~#set": [
|
||||||
|
"fdata/path-data",
|
||||||
|
"plugins/runtime",
|
||||||
|
"design-tokens/v1",
|
||||||
|
"layout/grid",
|
||||||
|
"styles/v2",
|
||||||
|
"fdata/pointer-map",
|
||||||
|
"fdata/objects-map",
|
||||||
|
"render-wasm/v1",
|
||||||
|
"components/v2",
|
||||||
|
"fdata/shape-data-type"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"~:team-id": "~u5d1327cf-3054-8111-8005-328a160ff966",
|
||||||
|
"~:permissions": {
|
||||||
|
"~:type": "~:membership",
|
||||||
|
"~:is-owner": true,
|
||||||
|
"~:is-admin": true,
|
||||||
|
"~:can-edit": true,
|
||||||
|
"~:can-read": true,
|
||||||
|
"~:is-logged": true
|
||||||
|
},
|
||||||
|
"~:has-media-trimmed": false,
|
||||||
|
"~:comment-thread-seqn": 0,
|
||||||
|
"~:name": "Exif rotated fills",
|
||||||
|
"~:revn": 17,
|
||||||
|
"~:modified-at": "~m1750761275050",
|
||||||
|
"~:vern": 0,
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a3912bdce8",
|
||||||
|
"~:is-shared": false,
|
||||||
|
"~:migrations": {
|
||||||
|
"~#ordered-set": [
|
||||||
|
"legacy-2",
|
||||||
|
"legacy-3",
|
||||||
|
"legacy-5",
|
||||||
|
"legacy-6",
|
||||||
|
"legacy-7",
|
||||||
|
"legacy-8",
|
||||||
|
"legacy-9",
|
||||||
|
"legacy-10",
|
||||||
|
"legacy-11",
|
||||||
|
"legacy-12",
|
||||||
|
"legacy-13",
|
||||||
|
"legacy-14",
|
||||||
|
"legacy-16",
|
||||||
|
"legacy-17",
|
||||||
|
"legacy-18",
|
||||||
|
"legacy-19",
|
||||||
|
"legacy-25",
|
||||||
|
"legacy-26",
|
||||||
|
"legacy-27",
|
||||||
|
"legacy-28",
|
||||||
|
"legacy-29",
|
||||||
|
"legacy-31",
|
||||||
|
"legacy-32",
|
||||||
|
"legacy-33",
|
||||||
|
"legacy-34",
|
||||||
|
"legacy-36",
|
||||||
|
"legacy-37",
|
||||||
|
"legacy-38",
|
||||||
|
"legacy-39",
|
||||||
|
"legacy-40",
|
||||||
|
"legacy-41",
|
||||||
|
"legacy-42",
|
||||||
|
"legacy-43",
|
||||||
|
"legacy-44",
|
||||||
|
"legacy-45",
|
||||||
|
"legacy-46",
|
||||||
|
"legacy-47",
|
||||||
|
"legacy-48",
|
||||||
|
"legacy-49",
|
||||||
|
"legacy-50",
|
||||||
|
"legacy-51",
|
||||||
|
"legacy-52",
|
||||||
|
"legacy-53",
|
||||||
|
"legacy-54",
|
||||||
|
"legacy-55",
|
||||||
|
"legacy-56",
|
||||||
|
"legacy-57",
|
||||||
|
"legacy-59",
|
||||||
|
"legacy-62",
|
||||||
|
"legacy-65",
|
||||||
|
"legacy-66",
|
||||||
|
"legacy-67",
|
||||||
|
"0001-remove-tokens-from-groups",
|
||||||
|
"0002-normalize-bool-content",
|
||||||
|
"0002-clean-shape-interactions",
|
||||||
|
"0003-fix-root-shape",
|
||||||
|
"0003-convert-path-content",
|
||||||
|
"0004-clean-shadow-and-colors",
|
||||||
|
"0005-deprecate-image-type",
|
||||||
|
"0006-fix-old-texts-fills",
|
||||||
|
"0007-clear-invalid-strokes-and-fills-v2",
|
||||||
|
"0008-fix-library-colors-opacity",
|
||||||
|
"0009-add-partial-text-touched-flags"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"~:version": 67,
|
||||||
|
"~:project-id": "~u5d1327cf-3054-8111-8005-340b8ba38a69",
|
||||||
|
"~:created-at": "~m1750761070908",
|
||||||
|
"~:data": {
|
||||||
|
"~:pages": [
|
||||||
|
"~u27270c45-35b4-80f3-8006-63a3912bdce9"
|
||||||
|
],
|
||||||
|
"~:pages-index": {
|
||||||
|
"~u27270c45-35b4-80f3-8006-63a3912bdce9": {
|
||||||
|
"~:objects": {
|
||||||
|
"~u00000000-0000-0000-0000-000000000000": {
|
||||||
|
"~#shape": {
|
||||||
|
"~:y": 0,
|
||||||
|
"~:hide-fill-on-export": false,
|
||||||
|
"~:transform": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:rotation": 0,
|
||||||
|
"~:name": "Root Frame",
|
||||||
|
"~:width": 0.01,
|
||||||
|
"~:type": "~:frame",
|
||||||
|
"~:points": [
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0,
|
||||||
|
"~:y": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0.01,
|
||||||
|
"~:y": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0.01,
|
||||||
|
"~:y": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0,
|
||||||
|
"~:y": 0.01
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:r2": 0,
|
||||||
|
"~:proportion-lock": false,
|
||||||
|
"~:transform-inverse": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:r3": 0,
|
||||||
|
"~:r1": 0,
|
||||||
|
"~:id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:strokes": [],
|
||||||
|
"~:x": 0,
|
||||||
|
"~:proportion": 1,
|
||||||
|
"~:r4": 0,
|
||||||
|
"~:selrect": {
|
||||||
|
"~#rect": {
|
||||||
|
"~:x": 0,
|
||||||
|
"~:y": 0,
|
||||||
|
"~:width": 0.01,
|
||||||
|
"~:height": 0.01,
|
||||||
|
"~:x1": 0,
|
||||||
|
"~:y1": 0,
|
||||||
|
"~:x2": 0.01,
|
||||||
|
"~:y2": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-color": "#FFFFFF",
|
||||||
|
"~:fill-opacity": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:flip-x": null,
|
||||||
|
"~:height": 0.01,
|
||||||
|
"~:flip-y": null,
|
||||||
|
"~:shapes": [
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a3d429cea3",
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a394f96940",
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a3ef35c521",
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a40defed29"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a394f96940": {
|
||||||
|
"~#shape": {
|
||||||
|
"~:y": -119,
|
||||||
|
"~:transform": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:rotation": 0,
|
||||||
|
"~:grow-type": "~:fixed",
|
||||||
|
"~:hide-in-viewer": false,
|
||||||
|
"~:name": "Rectangle",
|
||||||
|
"~:width": 1044,
|
||||||
|
"~:type": "~:rect",
|
||||||
|
"~:points": [
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:y": -119
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1167,
|
||||||
|
"~:y": -119
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1167,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:r2": 0,
|
||||||
|
"~:layout-item-h-sizing": "~:fix",
|
||||||
|
"~:proportion-lock": true,
|
||||||
|
"~:transform-inverse": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:layout-item-v-sizing": "~:fix",
|
||||||
|
"~:r3": 0,
|
||||||
|
"~:r1": 0,
|
||||||
|
"~:id": "~u8ae169c2-73c6-809f-8006-63a394f96940",
|
||||||
|
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:strokes": [],
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:proportion": 1.5,
|
||||||
|
"~:r4": 0,
|
||||||
|
"~:selrect": {
|
||||||
|
"~#rect": {
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:y": -119,
|
||||||
|
"~:width": 1044,
|
||||||
|
"~:height": 696,
|
||||||
|
"~:x1": -2211,
|
||||||
|
"~:y1": -119,
|
||||||
|
"~:x2": -1167,
|
||||||
|
"~:y2": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-opacity": 1,
|
||||||
|
"~:fill-image": {
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a39cf292e7",
|
||||||
|
"~:width": 1200,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:mtype": "image/jpeg",
|
||||||
|
"~:name": "Landscape_6.jpg",
|
||||||
|
"~:keep-aspect-ratio": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:flip-x": null,
|
||||||
|
"~:height": 696,
|
||||||
|
"~:flip-y": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a3d429cea3": {
|
||||||
|
"~#shape": {
|
||||||
|
"~:y": -119,
|
||||||
|
"~:transform": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:rotation": 0,
|
||||||
|
"~:grow-type": "~:fixed",
|
||||||
|
"~:hide-in-viewer": false,
|
||||||
|
"~:name": "Rectangle",
|
||||||
|
"~:width": 1044,
|
||||||
|
"~:type": "~:rect",
|
||||||
|
"~:points": [
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:y": -119
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -15,
|
||||||
|
"~:y": -119
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -15,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:r2": 0,
|
||||||
|
"~:layout-item-h-sizing": "~:fix",
|
||||||
|
"~:proportion-lock": true,
|
||||||
|
"~:transform-inverse": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:layout-item-v-sizing": "~:fix",
|
||||||
|
"~:r3": 0,
|
||||||
|
"~:r1": 0,
|
||||||
|
"~:id": "~u8ae169c2-73c6-809f-8006-63a3d429cea3",
|
||||||
|
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:strokes": [
|
||||||
|
{
|
||||||
|
"~:stroke-style": "~:solid",
|
||||||
|
"~:stroke-alignment": "~:inner",
|
||||||
|
"~:stroke-width": 200,
|
||||||
|
"~:stroke-opacity": 1,
|
||||||
|
"~:stroke-image": {
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a3ea82557f",
|
||||||
|
"~:width": 1200,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:mtype": "image/jpeg",
|
||||||
|
"~:name": "Landscape_6.jpg",
|
||||||
|
"~:keep-aspect-ratio": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:proportion": 1.5,
|
||||||
|
"~:r4": 0,
|
||||||
|
"~:selrect": {
|
||||||
|
"~#rect": {
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:y": -119,
|
||||||
|
"~:width": 1044,
|
||||||
|
"~:height": 696,
|
||||||
|
"~:x1": -1059,
|
||||||
|
"~:y1": -119,
|
||||||
|
"~:x2": -15,
|
||||||
|
"~:y2": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:fills": [],
|
||||||
|
"~:flip-x": null,
|
||||||
|
"~:height": 696,
|
||||||
|
"~:flip-y": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a3ef35c521": {
|
||||||
|
"~#shape": {
|
||||||
|
"~:y": 577,
|
||||||
|
"~:transform": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:rotation": 0,
|
||||||
|
"~:grow-type": "~:fixed",
|
||||||
|
"~:content": {
|
||||||
|
"~:type": "root",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:type": "paragraph-set",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:line-height": "1.2",
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:line-height": "1.2",
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:typography-ref-id": null,
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:text-align": "left",
|
||||||
|
"~:font-id": "sourcesanspro",
|
||||||
|
"~:font-size": "1500",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:typography-ref-file": null,
|
||||||
|
"~:text-direction": "ltr",
|
||||||
|
"~:font-variant-id": "regular",
|
||||||
|
"~:text-decoration": "none",
|
||||||
|
"~:letter-spacing": "0",
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-opacity": 1,
|
||||||
|
"~:fill-image": {
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a41d147866",
|
||||||
|
"~:width": 1200,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:mtype": "image/jpeg",
|
||||||
|
"~:name": "Landscape_6.jpg",
|
||||||
|
"~:keep-aspect-ratio": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:font-family": "sourcesanspro",
|
||||||
|
"~:text": "X"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:typography-ref-id": null,
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:text-align": "left",
|
||||||
|
"~:font-id": "sourcesanspro",
|
||||||
|
"~:key": "9nfs8",
|
||||||
|
"~:font-size": "1500",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:typography-ref-file": null,
|
||||||
|
"~:text-direction": "ltr",
|
||||||
|
"~:type": "paragraph",
|
||||||
|
"~:font-variant-id": "regular",
|
||||||
|
"~:text-decoration": "none",
|
||||||
|
"~:letter-spacing": "0",
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-opacity": 1,
|
||||||
|
"~:fill-image": {
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a41d147866",
|
||||||
|
"~:width": 1200,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:mtype": "image/jpeg",
|
||||||
|
"~:name": "Landscape_6.jpg",
|
||||||
|
"~:keep-aspect-ratio": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:font-family": "sourcesanspro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"~:hide-in-viewer": false,
|
||||||
|
"~:name": "X",
|
||||||
|
"~:width": 770,
|
||||||
|
"~:type": "~:text",
|
||||||
|
"~:points": [
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1441,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1441,
|
||||||
|
"~:y": 2377
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:y": 2377
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:layout-item-h-sizing": "~:fix",
|
||||||
|
"~:transform-inverse": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:layout-item-v-sizing": "~:fix",
|
||||||
|
"~:id": "~u8ae169c2-73c6-809f-8006-63a3ef35c521",
|
||||||
|
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:position-data": [
|
||||||
|
{
|
||||||
|
"~#rect": {
|
||||||
|
"~:y": 2448,
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:font-size": "1500px",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:y1": -71,
|
||||||
|
"~:width": 769.046875,
|
||||||
|
"~:text-decoration": "none solid rgb(0, 0, 0)",
|
||||||
|
"~:letter-spacing": "normal",
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:x1": 0,
|
||||||
|
"~:y2": 1871,
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-opacity": 1,
|
||||||
|
"~:fill-image": {
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a41d147866",
|
||||||
|
"~:width": 1200,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:mtype": "image/jpeg",
|
||||||
|
"~:name": "Landscape_6.jpg",
|
||||||
|
"~:keep-aspect-ratio": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:x2": 769.046875,
|
||||||
|
"~:direction": "ltr",
|
||||||
|
"~:font-family": "sourcesanspro",
|
||||||
|
"~:height": 1942,
|
||||||
|
"~:text": "X"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:selrect": {
|
||||||
|
"~#rect": {
|
||||||
|
"~:x": -2211,
|
||||||
|
"~:y": 577,
|
||||||
|
"~:width": 770,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:x1": -2211,
|
||||||
|
"~:y1": 577,
|
||||||
|
"~:x2": -1441,
|
||||||
|
"~:y2": 2377
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:flip-x": null,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:flip-y": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~u8ae169c2-73c6-809f-8006-63a40defed29": {
|
||||||
|
"~#shape": {
|
||||||
|
"~:y": 577,
|
||||||
|
"~:transform": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:rotation": 0,
|
||||||
|
"~:grow-type": "~:fixed",
|
||||||
|
"~:content": {
|
||||||
|
"~:type": "root",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:type": "paragraph-set",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:line-height": "1.2",
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:line-height": "1.2",
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:text-align": "left",
|
||||||
|
"~:font-id": "sourcesanspro",
|
||||||
|
"~:font-size": "1500",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:text-direction": "ltr",
|
||||||
|
"~:font-variant-id": "regular",
|
||||||
|
"~:text-decoration": "none",
|
||||||
|
"~:letter-spacing": "0",
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-color": "#B1B2B5",
|
||||||
|
"~:fill-opacity": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:font-family": "sourcesanspro",
|
||||||
|
"~:text": "X"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:text-align": "left",
|
||||||
|
"~:font-id": "sourcesanspro",
|
||||||
|
"~:key": "9nfs8",
|
||||||
|
"~:font-size": "1500",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:text-direction": "ltr",
|
||||||
|
"~:type": "paragraph",
|
||||||
|
"~:font-variant-id": "regular",
|
||||||
|
"~:text-decoration": "none",
|
||||||
|
"~:letter-spacing": "0",
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-color": "#B1B2B5",
|
||||||
|
"~:fill-opacity": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:font-family": "sourcesanspro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"~:hide-in-viewer": false,
|
||||||
|
"~:name": "X",
|
||||||
|
"~:width": 770,
|
||||||
|
"~:type": "~:text",
|
||||||
|
"~:points": [
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -289,
|
||||||
|
"~:y": 577
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -289,
|
||||||
|
"~:y": 2377
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:y": 2377
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:layout-item-h-sizing": "~:fix",
|
||||||
|
"~:transform-inverse": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1,
|
||||||
|
"~:b": 0,
|
||||||
|
"~:c": 0,
|
||||||
|
"~:d": 1,
|
||||||
|
"~:e": 0,
|
||||||
|
"~:f": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:layout-item-v-sizing": "~:fix",
|
||||||
|
"~:id": "~u8ae169c2-73c6-809f-8006-63a40defed29",
|
||||||
|
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:position-data": [
|
||||||
|
{
|
||||||
|
"~#rect": {
|
||||||
|
"~:y": 2448,
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:font-size": "1500px",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:y1": -71,
|
||||||
|
"~:width": 769.046875,
|
||||||
|
"~:text-decoration": "none solid rgb(177, 178, 181)",
|
||||||
|
"~:letter-spacing": "normal",
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:x1": 0,
|
||||||
|
"~:y2": 1871,
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-color": "#B1B2B5",
|
||||||
|
"~:fill-opacity": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:x2": 769.046875,
|
||||||
|
"~:direction": "ltr",
|
||||||
|
"~:font-family": "sourcesanspro",
|
||||||
|
"~:height": 1942,
|
||||||
|
"~:text": "X"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:strokes": [
|
||||||
|
{
|
||||||
|
"~:stroke-style": "~:solid",
|
||||||
|
"~:stroke-alignment": "~:outer",
|
||||||
|
"~:stroke-width": 100,
|
||||||
|
"~:stroke-opacity": 1,
|
||||||
|
"~:stroke-image": {
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a43dc4984b",
|
||||||
|
"~:width": 1200,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:mtype": "image/jpeg",
|
||||||
|
"~:name": "Landscape_6.jpg",
|
||||||
|
"~:keep-aspect-ratio": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:selrect": {
|
||||||
|
"~#rect": {
|
||||||
|
"~:x": -1059,
|
||||||
|
"~:y": 577,
|
||||||
|
"~:width": 770,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:x1": -1059,
|
||||||
|
"~:y1": 577,
|
||||||
|
"~:x2": -289,
|
||||||
|
"~:y2": 2377
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:flip-x": null,
|
||||||
|
"~:height": 1800,
|
||||||
|
"~:flip-y": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a3912bdce9",
|
||||||
|
"~:name": "Page 1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:id": "~u27270c45-35b4-80f3-8006-63a3912bdce8",
|
||||||
|
"~:options": {
|
||||||
|
"~:components-v2": true,
|
||||||
|
"~:base-font-size": "16px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -113,3 +113,28 @@ test("Renders shapes taking into account blend modes", async ({ page }) => {
|
||||||
|
|
||||||
await expect(workspace.canvas).toHaveScreenshot();
|
await expect(workspace.canvas).toHaveScreenshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Renders shapes with exif rotated images fills and strokes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = new WasmWorkspacePage(page);
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockFileMediaAsset(
|
||||||
|
[
|
||||||
|
"27270c45-35b4-80f3-8006-63a39cf292e7",
|
||||||
|
"27270c45-35b4-80f3-8006-63a41d147866",
|
||||||
|
"27270c45-35b4-80f3-8006-63a43dc4984b",
|
||||||
|
"27270c45-35b4-80f3-8006-63a3ea82557f"
|
||||||
|
],
|
||||||
|
"render-wasm/assets/landscape.jpg",
|
||||||
|
);
|
||||||
|
await workspace.mockGetFile("render-wasm/get-file-shapes-exif-rotated-fills.json");
|
||||||
|
|
||||||
|
await workspace.goToWorkspace({
|
||||||
|
id: "27270c45-35b4-80f3-8006-63a3912bdce8",
|
||||||
|
pageId: "27270c45-35b4-80f3-8006-63a3912bdce9",
|
||||||
|
});
|
||||||
|
await workspace.waitForFirstRender();
|
||||||
|
|
||||||
|
await expect(workspace.canvas).toHaveScreenshot();
|
||||||
|
});
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 629 KiB |
|
@ -1,7 +1,7 @@
|
||||||
use skia_safe::{self as skia, Paint, RRect};
|
use skia_safe::{self as skia, Paint, RRect};
|
||||||
|
|
||||||
use super::{RenderState, SurfaceId};
|
use super::{RenderState, SurfaceId};
|
||||||
use crate::math::Rect as MathRect;
|
use crate::render::get_source_rect;
|
||||||
use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type};
|
use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type};
|
||||||
|
|
||||||
fn draw_image_fill(
|
fn draw_image_fill(
|
||||||
|
@ -16,43 +16,13 @@ fn draw_image_fill(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let size = image_fill.size();
|
let size = image.unwrap().dimensions();
|
||||||
let canvas = render_state.surfaces.canvas(SurfaceId::Fills);
|
let canvas = render_state.surfaces.canvas(SurfaceId::Fills);
|
||||||
let container = &shape.selrect;
|
let container = &shape.selrect;
|
||||||
let path_transform = shape.to_path_transform();
|
let path_transform = shape.to_path_transform();
|
||||||
|
|
||||||
let width = size.0 as f32;
|
let src_rect = get_source_rect(size, container, image_fill);
|
||||||
let height = size.1 as f32;
|
let dest_rect = container;
|
||||||
|
|
||||||
// Container size
|
|
||||||
let container_width = container.width();
|
|
||||||
let container_height = container.height();
|
|
||||||
|
|
||||||
let mut scaled_width = container_width;
|
|
||||||
let mut scaled_height = container_height;
|
|
||||||
|
|
||||||
if image_fill.keep_aspect_ratio() {
|
|
||||||
// Calculate scale to ensure the image covers the container
|
|
||||||
let image_aspect_ratio = width / height;
|
|
||||||
let container_aspect_ratio = container_width / container_height;
|
|
||||||
let scale = if image_aspect_ratio > container_aspect_ratio {
|
|
||||||
// Image is wider, scale based on height to cover container
|
|
||||||
container_height / height
|
|
||||||
} else {
|
|
||||||
// Image is taller, scale based on width to cover container
|
|
||||||
container_width / width
|
|
||||||
};
|
|
||||||
// Scaled size of the image
|
|
||||||
scaled_width = width * scale;
|
|
||||||
scaled_height = height * scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dest_rect = MathRect::from_xywh(
|
|
||||||
container.left - (scaled_width - container_width) / 2.0,
|
|
||||||
container.top - (scaled_height - container_height) / 2.0,
|
|
||||||
scaled_width,
|
|
||||||
scaled_height,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save the current canvas state
|
// Save the current canvas state
|
||||||
canvas.save();
|
canvas.save();
|
||||||
|
@ -99,7 +69,7 @@ fn draw_image_fill(
|
||||||
if let Some(image) = image {
|
if let Some(image) = image {
|
||||||
canvas.draw_image_rect_with_sampling_options(
|
canvas.draw_image_rect_with_sampling_options(
|
||||||
image,
|
image,
|
||||||
None,
|
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||||
dest_rect,
|
dest_rect,
|
||||||
render_state.sampling_options,
|
render_state.sampling_options,
|
||||||
paint,
|
paint,
|
||||||
|
|
|
@ -1,12 +1,57 @@
|
||||||
use crate::math::Rect as MathRect;
|
use crate::math::Rect as MathRect;
|
||||||
|
use crate::shapes::ImageFill;
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
|
|
||||||
use skia_safe as skia;
|
|
||||||
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
|
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
|
||||||
|
use skia_safe::{self as skia, Codec, ISize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub type Image = skia::Image;
|
pub type Image = skia::Image;
|
||||||
|
|
||||||
|
pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect {
|
||||||
|
MathRect::from_ltrb(
|
||||||
|
container.left - delta,
|
||||||
|
container.top - delta,
|
||||||
|
container.right + delta,
|
||||||
|
container.bottom + delta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect {
|
||||||
|
let image_width = size.width as f32;
|
||||||
|
let image_height = size.height as f32;
|
||||||
|
|
||||||
|
// Container size
|
||||||
|
let container_width = container.width();
|
||||||
|
let container_height = container.height();
|
||||||
|
|
||||||
|
let mut source_width = image_width;
|
||||||
|
let mut source_height = image_height;
|
||||||
|
let mut source_x = 0.;
|
||||||
|
let mut source_y = 0.;
|
||||||
|
|
||||||
|
let source_scale_y = image_height / container_height;
|
||||||
|
let source_scale_x = image_width / container_width;
|
||||||
|
|
||||||
|
if image_fill.keep_aspect_ratio() {
|
||||||
|
// Calculate scale to ensure the image covers the container
|
||||||
|
let image_aspect_ratio = image_width / image_height;
|
||||||
|
let container_aspect_ratio = container_width / container_height;
|
||||||
|
|
||||||
|
if image_aspect_ratio > container_aspect_ratio {
|
||||||
|
// Image is taller, scale based on width to cover container
|
||||||
|
source_width = container_width * source_scale_y;
|
||||||
|
source_x = (image_width - source_width) / 2.0;
|
||||||
|
} else {
|
||||||
|
// Image is wider, scale based on height to cover container
|
||||||
|
source_height = container_height * source_scale_x;
|
||||||
|
source_y = (image_height - source_height) / 2.0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
MathRect::from_xywh(source_x, source_y, source_width, source_height)
|
||||||
|
}
|
||||||
|
|
||||||
enum StoredImage {
|
enum StoredImage {
|
||||||
Raw(Vec<u8>),
|
Raw(Vec<u8>),
|
||||||
Gpu(Image),
|
Gpu(Image),
|
||||||
|
@ -47,12 +92,16 @@ impl ImageStore {
|
||||||
StoredImage::Raw(raw_data) => {
|
StoredImage::Raw(raw_data) => {
|
||||||
// Decode and upload to GPU
|
// Decode and upload to GPU
|
||||||
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
||||||
let image = Image::from_encoded(data)?;
|
let codec = Codec::from_data(data.clone())?;
|
||||||
|
let image = Image::from_encoded(data.clone())?;
|
||||||
|
|
||||||
let width = image.width();
|
let mut dimensions = codec.dimensions();
|
||||||
let height = image.height();
|
if codec.origin().swaps_width_height() {
|
||||||
|
dimensions.width = codec.dimensions().height;
|
||||||
|
dimensions.height = codec.dimensions().width;
|
||||||
|
}
|
||||||
|
|
||||||
let image_info = skia::ImageInfo::new_n32_premul((width, height), None);
|
let image_info = skia::ImageInfo::new_n32_premul(dimensions, None);
|
||||||
|
|
||||||
let mut surface = surfaces::render_target(
|
let mut surface = surfaces::render_target(
|
||||||
&mut self.context,
|
&mut self.context,
|
||||||
|
@ -65,7 +114,12 @@ impl ImageStore {
|
||||||
false,
|
false,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let dest_rect = MathRect::from_xywh(0.0, 0.0, width as f32, height as f32);
|
let dest_rect: MathRect = MathRect::from_xywh(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
dimensions.width as f32,
|
||||||
|
dimensions.height as f32,
|
||||||
|
);
|
||||||
|
|
||||||
surface.canvas().draw_image_rect(
|
surface.canvas().draw_image_rect(
|
||||||
&image,
|
&image,
|
||||||
|
|
|
@ -7,6 +7,7 @@ use skia_safe::{self as skia, textlayout::Paragraph, ImageFilter, RRect};
|
||||||
|
|
||||||
use super::{RenderState, SurfaceId};
|
use super::{RenderState, SurfaceId};
|
||||||
use crate::render::text::{self};
|
use crate::render::text::{self};
|
||||||
|
use crate::render::{get_dest_rect, get_source_rect};
|
||||||
|
|
||||||
// FIXME: See if we can simplify these arguments
|
// FIXME: See if we can simplify these arguments
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
@ -346,42 +347,6 @@ fn draw_triangle_cap(
|
||||||
canvas.draw_path(&path, paint);
|
canvas.draw_path(&path, paint);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_scaled_rect(
|
|
||||||
size: (i32, i32),
|
|
||||||
container: &Rect,
|
|
||||||
delta: f32,
|
|
||||||
keep_aspect_ratio: bool,
|
|
||||||
) -> Rect {
|
|
||||||
let (width, height) = (size.0 as f32, size.1 as f32);
|
|
||||||
|
|
||||||
// Container size
|
|
||||||
let container_width = container.width();
|
|
||||||
let container_height = container.height();
|
|
||||||
|
|
||||||
let mut scaled_width = container_width;
|
|
||||||
let mut scaled_height = container_height;
|
|
||||||
|
|
||||||
if keep_aspect_ratio {
|
|
||||||
let image_aspect_ratio = width / height;
|
|
||||||
let container_aspect_ratio = container_width / container_height;
|
|
||||||
let scale = if image_aspect_ratio > container_aspect_ratio {
|
|
||||||
container_height / height
|
|
||||||
} else {
|
|
||||||
container_width / width
|
|
||||||
};
|
|
||||||
|
|
||||||
scaled_width = width * scale;
|
|
||||||
scaled_height = height * scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect::from_xywh(
|
|
||||||
container.left - delta - (scaled_width - container_width) / 2.0,
|
|
||||||
container.top - delta - (scaled_height - container_height) / 2.0,
|
|
||||||
scaled_width + (2. * delta) + (scaled_width - container_width),
|
|
||||||
scaled_height + (2. * delta) + (scaled_width - container_width),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_image_stroke_in_container(
|
fn draw_image_stroke_in_container(
|
||||||
render_state: &mut RenderState,
|
render_state: &mut RenderState,
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
|
@ -395,7 +360,7 @@ fn draw_image_stroke_in_container(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let size = image_fill.size();
|
let size = image.unwrap().dimensions();
|
||||||
let canvas = render_state.surfaces.canvas(SurfaceId::Strokes);
|
let canvas = render_state.surfaces.canvas(SurfaceId::Strokes);
|
||||||
let container = &shape.selrect;
|
let container = &shape.selrect;
|
||||||
let path_transform = shape.to_path_transform();
|
let path_transform = shape.to_path_transform();
|
||||||
|
@ -486,17 +451,13 @@ fn draw_image_stroke_in_container(
|
||||||
image_paint.set_blend_mode(skia::BlendMode::SrcIn);
|
image_paint.set_blend_mode(skia::BlendMode::SrcIn);
|
||||||
image_paint.set_anti_alias(antialias);
|
image_paint.set_anti_alias(antialias);
|
||||||
|
|
||||||
// Compute scaled rect and clip to it
|
let src_rect = get_source_rect(size, container, image_fill);
|
||||||
let dest_rect = calculate_scaled_rect(
|
let dest_rect = get_dest_rect(container, stroke.delta());
|
||||||
size,
|
|
||||||
container,
|
|
||||||
stroke.delta(),
|
|
||||||
image_fill.keep_aspect_ratio(),
|
|
||||||
);
|
|
||||||
canvas.clip_rect(dest_rect, skia::ClipOp::Intersect, antialias);
|
canvas.clip_rect(dest_rect, skia::ClipOp::Intersect, antialias);
|
||||||
canvas.draw_image_rect_with_sampling_options(
|
canvas.draw_image_rect_with_sampling_options(
|
||||||
image.unwrap(),
|
image.unwrap(),
|
||||||
None,
|
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||||
dest_rect,
|
dest_rect,
|
||||||
render_state.sampling_options,
|
render_state.sampling_options,
|
||||||
&image_paint,
|
&image_paint,
|
||||||
|
|
|
@ -115,10 +115,6 @@ impl ImageFill {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn size(&self) -> (i32, i32) {
|
|
||||||
(self.width, self.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> Uuid {
|
pub fn id(&self) -> Uuid {
|
||||||
self.id
|
self.id
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue