diff --git a/README.md b/README.md index c47207c..55ef6a1 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,23 @@ -# TapTo Designer +# Zaparoo Designer -This is a companion app to the TapTo system -You can read more about TapTo [here](https://zaparoo.org) +A companion app to the Zaparoo project. The Designer allows you to easily generate Zaparoo labels from templates, ready to print and cut, right from your web browser using external sources for game media. Read more about the Zaparoo project at [Zaparoo.org](https://zaparoo.org/). -# How to use it +Zaparoo Designer is officially hosted at: [design.zaparoo.org](https://design.zaparoo.org/) +# Usage This app is deployed at [https://design.zaparoo.org](https://design.zaparoo.org/) and you can just use it, providing your own images or using the embedded search functionality. It has no server, no login, no way to save progress. Is meant for producing labels and download the result right away. Everything runs locally, your images aren't unploaded to any server. -You can load files by drag and drop or by browsing your file system +1. Navigate to: [design.zaparoo.org](https://design.zaparoo.org/) +2. Select `Add files` to upload a local image or `Search image` to search and load an image from [IGDB](https://www.igdb.com/). +3. Make any template, color or media transformations you like to the final label. +4. Repeat steps 2 and 3 for each label you want to create this session. +5. Select `Print` and configure the output that works best for your printer or plotter. +6. Press `Download`. +7. Print the output PDF or PNG files on your printer. -image +## Trademarks -Once you load some files you are presented with a default grid of cards with the default template: - -image +This repository contains Zaparoo trademark assets which are explicitly licensed to this project in this location by the trademark owner. These trademarks must be removed from the project or replaced if you intend to redistribute or adapt the project in any form. See the Zaparoo [Terms of Use](https://zaparoo.org/terms/) for further details. diff --git a/TEMPLATES.md b/TEMPLATES.md new file mode 100644 index 0000000..88c1abc --- /dev/null +++ b/TEMPLATES.md @@ -0,0 +1,165 @@ +# Templates Format + +Templates are in [SVG format](https://www.w3.org/TR/SVG11/). + +Zaparoo Designer uses [FabricJS](https://fabricjs.com/) to parse/render templates on a canvas and export them on a printable PDF. It is limited by the features FabricJS supports. + +## General guidelines + +Designer supports the following SVG tags: + +- Image +- Rect +- Path +- Gradient + +It's recommended to use a viewport that starts from 0,0 and to have no content bleeding outside the viewport. + +The preferred unit for measurement is in pixels and assuming a final print of 300dpi. For example, if you are working for a 2 by 3 inch label, it is advised to work with a 600 by 900 canvas and a viewBox that is `0 0 600 900`. + +The template will be scaled anyway to fit the media you assign it to, more on that later. + +Templates contributed to the Zaparoo Designer repository must not contain and copyrighted material or infringe on any trademarks, including Zaparoo trademarks. + +A license must be included in all contributed templates using an XML comment. The license is your choice, but we recommend some variant of the [Creative Commons](https://creativecommons.org/) licenses that works best for your preferences. You can use the [Creative Common License Chooser](https://chooser-beta.creativecommons.org/) to easily pick the most appropriate one. + +## Tools + +Any application that let you edit SVG is good for this, one free application is [Inkscape](https://inkscape.org/), others are good as well and if you have examples to add to this guide you can contribute with a PR. + +[SVGOmg](https://svgomg.net/) is a tool that will let you improve your svg size and complexity in case Inkscape or your favorite application would add too many tags and attributes that we do not really need + +## The media dimensions + +Templates on this application are meant to be customized and printed and fit or stick somewhere. As a consequence the template is made with a single size in mind. There may be templates that can be scaled freely to different sizes of the same aspect ratio, but we do not handle that. + +The media dimensions are specified in the width and height of the main svg tag. +To make an example a standard nfc card is sized at 3.375 inches high by 2.125 inches wide. That make at 300dpi: 1012.5 by 637,5 pixels. Now if you are making a sticker for the nfc card you probably want to leave a bit of wiggle room for applying the sticker, or maybe no you want to go larger and then refine the borders with a knife. In this example we leave some border so that it can be applied safely and we pick up 994 by 619 that is a decent fit, and then we will leave some extra white space in the template itself. + +If you are creating an horizontal or vertical template that depends on your taste and you will have to use width and height accordingly. + +The resulting SVG initial tag would look like something like this: + +```xml + + + +``` + +## The template layers + +The SVG and so your template will be a stack of layers. +There will be a spot for one or more images between the stack. +Let's take for example a simple template for the mini nfc cards. +In the example i m going to omit the long path for the purpose of formatting and reading. So the xml you find here is a showcase and doesn't display correct graphics. + +```xml + + + + + + + + + + + + + + +``` + +As you can see there are 10 elements in this SVG, some of them are in a group that is not important to us, is how the design application packaged it. + +The elements are drawn top to bottom, meaning that the top element is drawn first, and so is behind the others. If you are not used to svgs it is the opposite of what you would think. Top elements are at the bottom. + +In this case the first element is a special one: + +```xml + +``` + +It has a special attribute: `zaparoo-placeholder` of value `main`, that means that this is the object that is going to contain your card image and define its exentension and position. +x, y, width and height define where this placholder is. +This rect has also `zaparoo-fill-strategy="fit"` meaning that the image will be scaled to fit the rectangle. + +More attributes with different values are going to be implmented in the future ( image in a circle or custom path for example, more images, text placeholders ) + +The visual attributes of the placeholder are purely for visual reference and won't be displayed in the designer. + +The template file looks like this: + +![template example](/docs/template_example.png) + +But When using it the template red dashed dot will not be visible. + +Once the template is ready you need to add it to the template list. + +```ts + minNfc: { + version: 2, // specify version as 2, fixed + layout: 'vertical', // vertical or horzintal + label: 'Steam 3by5cm', // a name of your liking + url: mininfcAlice, // the url of the file you create, more on this below. + author: Authors.alice, // Reference YOU as the author. more on this below + media: miniNfcCard, // reference the media size, create a new one if necessary + key: 'miniNfcAlice', // a unique string for some reason i really forgot + }, +``` + +This javascript object goes in this file here: + +src/cardsTemplates.ts + +And it looks like this: + +```ts +import mininfcAlice from './assets/3by5_steam.svg'; // where your SVG is placed +import { Authors } from './templateAuthors'; // where the authors are defined + +import { miniNfcCard } from './printMediaTypes'; // where the media types are defined + +export const templates: Record = { + blankH: { + layout: 'horizontal', + label: 'Blank H cover', + author: Authors.andrea, + media: NFCCCsizeCard, + key: 'blankH', + }, + // ... many templates after + miniNfcAlice: { + version: 2, + layout: 'vertical', + label: 'Steam 3by5cm', + url: mininfcAlice, + author: Authors.alice, + media: miniNfcCard, + key: 'miniNfcAlice', + }, +} as const; + +export const defaultTemplateKey = 'hucard'; +export const defaultTemplate = templates[defaultTemplateKey]; +``` + +If you want to add your templates and you are willing to do the work yourself, please read this guide and if it doesn't work ask help in discord or with an issue, explaining what part is unclear or where you are stuck at. diff --git a/docs/template_example.png b/docs/template_example.png new file mode 100644 index 0000000..f6953b1 Binary files /dev/null and b/docs/template_example.png differ diff --git a/src/assets/3by5_steam.svg b/src/assets/3by5_steam.svg index 1289974..a94f9d9 100644 --- a/src/assets/3by5_steam.svg +++ b/src/assets/3by5_steam.svg @@ -1,44 +1,24 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/tapto_vertical.svg b/src/assets/tapto_vertical.svg index 35b65d0..d2d6a5c 100644 --- a/src/assets/tapto_vertical.svg +++ b/src/assets/tapto_vertical.svg @@ -1,86 +1,23 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/cardsTemplates.ts b/src/cardsTemplates.ts index 29e52d5..27c79c0 100644 --- a/src/cardsTemplates.ts +++ b/src/cardsTemplates.ts @@ -24,12 +24,12 @@ import cassetTape from './assets/cassette_tape.svg'; import mininfcAlice from './assets/3by5_steam.svg'; import cardFront from './assets/cardfront.png'; import { Authors } from './templateAuthors'; -import { templateType } from './resourcesTypedef'; +import type { templateType, templateTypeV2 } from './resourcesTypedef'; import { logoResource } from './logos'; import { NFCCCsizeCard, TapeBoxCover, tapToPrePrinted, miniNfcCard } from './printMediaTypes'; -export const templates: Record = { +export const templates: Record = { blankH: { layout: 'horizontal', label: 'Blank H cover', @@ -171,24 +171,10 @@ export const templates: Record = { key: 'tapto2', }, tapto3: { + version: 2, layout: 'vertical', - overlay: { - layerWidth: 619, - layerHeight: 994, - url: tapToVertical, - width: 1 - (37 * 2) / 619, - height: 1 - (37 + 144) / 994, - x: 37 / 619, - y: 37 / 994, - isSvg: true, - }, shadow: '0px 10px 20px rgba(0, 0, 0, 0.3)', - background: { - layerWidth: 619, - layerHeight: 994, - url: tapToBgV, - isSvg: true, - }, + url: tapToVertical, label: 'Tap-to V', author: Authors.tim, media: NFCCCsizeCard, @@ -489,18 +475,10 @@ export const templates: Record = { key: 'cassetteBoxV2', }, miniNfcAlice: { + version: 2, layout: 'vertical', label: 'Steam 3by5cm', - overlay: { - url: mininfcAlice, - isSvg: true, - layerWidth: 354, - layerHeight: 591, - height: 445/591, - width: 305/354, - y: 117/591, - x: 25/354, - }, + url: mininfcAlice, author: Authors.alice, media: miniNfcCard, key: 'miniNfcAlice', diff --git a/src/components/Carousel.tsx b/src/components/Carousel.tsx index aa5a635..717d4a2 100644 --- a/src/components/Carousel.tsx +++ b/src/components/Carousel.tsx @@ -4,7 +4,7 @@ import { mediaTargetList } from '../printMediaTypes'; import './Carousel.css'; import { useAppDataContext } from '../contexts/appData'; import { templateAuthors } from '../templateAuthors'; -import type { templateType } from '../resourcesTypedef'; +import type { templateType, templateTypeV2 } from '../resourcesTypedef'; import { useState, useLayoutEffect, @@ -32,7 +32,9 @@ const TemplatesCarousel = memo(() => { setItems( availableTemplates.filter( (tData) => - (!!tData.overlay || !!tData.background) && + (!!tData.overlay || + !!tData.background || + (tData as templateTypeV2).version === 2) && !tData.key.includes('blank'), ), ); diff --git a/src/components/DataToCanvasReconciler.tsx b/src/components/DataToCanvasReconciler.tsx index 49f0e75..9da2bd3 100644 --- a/src/components/DataToCanvasReconciler.tsx +++ b/src/components/DataToCanvasReconciler.tsx @@ -3,6 +3,8 @@ import { useAppDataContext } from '../contexts/appData'; import { CardData, useFileDropperContext } from '../contexts/fileDropper'; import { setTemplateOnCanvases } from '../utils/setTemplate'; import { updateColors } from '../utils/updateColors'; +import { isTemplateV2 } from '../utils/utils'; +import { setTemplateV2OnCanvases } from '../utils/setTemplateV2'; export const DataToCanvasReconciler = () => { const { cards } = useFileDropperContext(); @@ -20,11 +22,19 @@ export const DataToCanvasReconciler = () => { if (cards.current.length) { setIsIdle(false); const selectedCards = cards.current.filter((card) => card.isSelected); - setTemplateOnCanvases(selectedCards, template).then((colors) => { - setOriginalColors(colors); - setCustomColors(colors); - setIsIdle(true); - }); + if (isTemplateV2(template)) { + setTemplateV2OnCanvases(selectedCards, template).then((colors) => { + setOriginalColors(colors); + setCustomColors(colors); + setIsIdle(true); + }); + } else { + setTemplateOnCanvases(selectedCards, template).then((colors) => { + setOriginalColors(colors); + setCustomColors(colors); + setIsIdle(true); + }); + } } }, [template, setCustomColors, cards, setOriginalColors, setIsIdle]); @@ -38,12 +48,22 @@ export const DataToCanvasReconciler = () => { (card): card is Required => !!card.isSelected && !!card.canvas, ); setIsIdle(false); - setTemplateOnCanvases(selectedCardsWithDifferentTemplate, template).then( - () => { + if (isTemplateV2(template)) { + setTemplateV2OnCanvases( + selectedCardsWithDifferentTemplate, + template, + ).then(() => { updateColors(selectedCards, customColors, originalColors); setIsIdle(true); - }, - ); + }); + } else { + setTemplateOnCanvases(selectedCardsWithDifferentTemplate, template).then( + () => { + updateColors(selectedCards, customColors, originalColors); + setIsIdle(true); + }, + ); + } }, [cards, customColors, originalColors, setIsIdle, template]); return null; diff --git a/src/contexts/fileDropper.ts b/src/contexts/fileDropper.ts index bc92941..f3d55c9 100644 --- a/src/contexts/fileDropper.ts +++ b/src/contexts/fileDropper.ts @@ -4,6 +4,7 @@ import type { StaticCanvas } from 'fabric'; import type { templateType } from '../resourcesTypedef'; export type CardData = { + /* the source of the main image */ file: File | HTMLImageElement, canvas?: StaticCanvas; template?: templateType; diff --git a/src/extensions/fabricToPdfKit.ts b/src/extensions/fabricToPdfKit.ts index d47a4c4..c9b3484 100644 --- a/src/extensions/fabricToPdfKit.ts +++ b/src/extensions/fabricToPdfKit.ts @@ -76,14 +76,16 @@ const addRectToPdf = ( pdfDoc.save(); transformPdf(rect, pdfDoc); - if (rect.rx || rect.ry) { - console.warn('Missing code for Rect rounded corners') - } + const hasRounds = rect.rx || rect.ry; const paintStroke = () => { if (rect.stroke && rect.stroke !== 'transparent') { const stroke = toPdfColor(rect.stroke, pdfDoc, rect); pdfDoc.lineWidth(rect.strokeWidth); - pdfDoc.rect(-rect.width / 2, -rect.height / 2, rect.width, rect.height); + if (hasRounds) { + pdfDoc.roundedRect(-rect.width / 2, -rect.height / 2, rect.width, rect.height, rect.rx) + } else { + pdfDoc.rect(-rect.width / 2, -rect.height / 2, rect.width, rect.height,); + } // pdfDoc.strokeOpacity(this.opacity); if ( rect.strokeDashArray && @@ -102,7 +104,11 @@ const addRectToPdf = ( if (rect.fill && rect.fill !== 'transparent') { const fill = toPdfColor(rect.fill, pdfDoc, rect); // pdfDoc.fillOpacity(this.opacity); - pdfDoc.rect(-rect.width / 2, -rect.height / 2, rect.width, rect.height); + if (hasRounds) { + pdfDoc.roundedRect(-rect.width / 2, -rect.height / 2, rect.width, rect.height, rect.rx) + } else { + pdfDoc.rect(-rect.width / 2, -rect.height / 2, rect.width, rect.height,); + } pdfDoc.fill(fill); } }; @@ -162,10 +168,7 @@ const transformPdf = (fabricObject: FabricObject, pdfDoc: any) => { const handleAbsoluteClipPath = (clipPath: FabricObject, pdfDoc: any) => { transformPdf(clipPath as FabricObject, pdfDoc); if (clipPath instanceof Rect) { - if (clipPath.rx || clipPath.ry) { - console.warn('Missing code for rounded corners rect clipPath') - } - pdfDoc.roundedRect(-clipPath.width / 2, -clipPath.height / 2, clipPath.width, clipPath.height, 0).clip(); + pdfDoc.roundedRect(-clipPath.width / 2, -clipPath.height / 2, clipPath.width, clipPath.height, clipPath.rx).clip(); } const matrix = clipPath.calcOwnMatrix(); pdfDoc.transform(...util.invertTransform(matrix)); @@ -212,14 +215,11 @@ const addImageToPdf = async ( pdfDoc.restore(); }; -const addGroupToPdf = async ( - group: Group, - pdfDoc: any, -) => { - pdfDoc.save(); - transformPdf(group, pdfDoc); - const objs = group.getObjects(); +const addObjectsToPdf = async (objs: FabricObject[], pdfDoc: any) => { for ( const object of objs) { + if (!object.visible) { + continue; + } if (object instanceof Path) { addPathToPdf(object, pdfDoc); } @@ -230,6 +230,16 @@ const addGroupToPdf = async ( await addImageToPdf(object, pdfDoc); } } +} + +const addGroupToPdf = async ( + group: Group, + pdfDoc: any, +) => { + pdfDoc.save(); + transformPdf(group, pdfDoc); + const objs = group.getObjects(); + await addObjectsToPdf(objs, pdfDoc); pdfDoc.restore(); }; @@ -275,15 +285,13 @@ export const addCanvasToPdfPage = async ( if (!template.background?.hidePrint) { if (canvas.backgroundImage instanceof Group) { await addGroupToPdf(canvas.backgroundImage, pdfDoc); - } else { + } else if (canvas.backgroundImage) { // add it as an image. // no usecase for this yet console.warn('Missing code to add images to pdf from background') } } - const mainImage = canvas.getObjects('image')[0] as FabricImage; - await addImageToPdf(mainImage, pdfDoc); - + await addObjectsToPdf(canvas.getObjects(), pdfDoc); if (canvas.overlayImage instanceof Group) { await addGroupToPdf(canvas.overlayImage, pdfDoc); } else { diff --git a/src/hooks/useLabelEditor.ts b/src/hooks/useLabelEditor.ts index d53a1c8..a4cc0b1 100644 --- a/src/hooks/useLabelEditor.ts +++ b/src/hooks/useLabelEditor.ts @@ -6,6 +6,8 @@ import { import { util, FabricImage, type StaticCanvas } from 'fabric'; import { useAppDataContext } from '../contexts/appData'; import { updateColors } from '../utils/updateColors'; +import { isTemplateV2 } from '../utils/utils'; +import { setTemplateV2OnCanvases } from '../utils/setTemplateV2'; type useLabelEditorParams = { padderRef: MutableRefObject; @@ -37,7 +39,7 @@ export const useLabelEditor = ({ } setImageReady(false); imagePromise.then((image) => { - const fabricImage = new FabricImage(image); + const fabricImage = new FabricImage(image, { resourceType: "main" }); // @ts-expect-error no originalFile fabricImage.originalFile = file; const scale = util.findScaleToCover(fabricImage, fabricCanvas); @@ -66,10 +68,17 @@ export const useLabelEditor = ({ card.template = template; card.colors = customColors; card.originalColors = originalColors; - setTemplateOnCanvases([card], template).then(() => { - updateColors([card], customColors, originalColors); - fabricCanvas.requestRenderAll(); - }); + if (isTemplateV2(template)) { + setTemplateV2OnCanvases([card], template).then(() => { + updateColors([card], customColors, originalColors); + fabricCanvas.requestRenderAll(); + }); + } else { + setTemplateOnCanvases([card], template).then(() => { + updateColors([card], customColors, originalColors); + fabricCanvas.requestRenderAll(); + }); + } } // shouldn't retrigger for index change or template change or colors // the data reconciler does that diff --git a/src/resourcesTypedef.ts b/src/resourcesTypedef.ts index a4a7fab..5d8ee94 100644 --- a/src/resourcesTypedef.ts +++ b/src/resourcesTypedef.ts @@ -1,4 +1,4 @@ -import { type SerializedGroupProps } from 'fabric'; +import { type Group, type SerializedGroupProps } from 'fabric'; import type { Authors } from './templateAuthors'; export type templateLayer = { @@ -87,4 +87,17 @@ export type templateType = { media: MediaDefinition; printableAreas?: PrintableArea[], key: string; +}; + +export type templateTypeV2 = { + parsed?: Promise; + version: number; + layout: layoutOrientation; + url: string; + label: string; + /* a reference to the author data */ + author: Authors; + media: MediaDefinition; + printableAreas?: PrintableArea[], + key: string; }; \ No newline at end of file diff --git a/src/utils/preparePdfKit.ts b/src/utils/preparePdfKit.ts index 976327c..d44bfb1 100644 --- a/src/utils/preparePdfKit.ts +++ b/src/utils/preparePdfKit.ts @@ -52,8 +52,8 @@ export const preparePdf = async ( // take first card and compare with paper size, then try to guess best way to fit const firstCard = cards[0]; const firstCardTemplate = firstCard.template!; - let widthInPt = fromPixToPoint(firstCardTemplate.layout === 'horizontal' ? firstCardTemplate.media.width : firstCardTemplate.media.height); - let heightInPt = fromPixToPoint(firstCardTemplate.layout === 'horizontal' ? firstCardTemplate.media.height : firstCardTemplate.media.width); + let widthInPt = fromPixToPoint(firstCardTemplate.media.width); + let heightInPt = fromPixToPoint(firstCardTemplate.media.height); const availPaperWidth = paperWidthInPt - leftMarginInPt - rightMarginInPt; const availPaperHeight = paperHeightInPt - topMarginInPt - bottomMarginInPt; @@ -61,7 +61,7 @@ export const preparePdf = async ( // the template media is counted always as horizontal/landscape. // isRotated means that it fits more prints if rotated as vertical // the paper template instead is defined as you would insert in the printer. - let isRotated = false; + let neutralTemplate: 'horizontal' | 'vertical' = 'horizontal'; if (!_tmpColumns && !_tmpRows) { // naively divide width by width and height by height, find margins and spacing. const possibleRows = Math.floor(availPaperHeight / heightInPt); @@ -80,7 +80,7 @@ export const preparePdf = async ( if (Math.abs(marginsW - marginsH) > Math.abs(marginsWR - marginsHR)) { rows = possibleRowsRotated; columns = possibleColumsRotated; - isRotated = true; + neutralTemplate = 'vertical'; } else { rows = possibleRows; columns = possibleColums; @@ -90,13 +90,13 @@ export const preparePdf = async ( rows = possibleRows; columns = possibleColums; } else { - isRotated = true; + neutralTemplate = 'vertical'; rows = possibleRowsRotated; columns = possibleColumsRotated; } } - if (isRotated) { + if (neutralTemplate === 'vertical') { [widthInPt, heightInPt] = [heightInPt, widthInPt]; } @@ -169,7 +169,7 @@ export const preparePdf = async ( } } - const needsRotation = isRotated || cards[index].template!.layout !== firstCardTemplate.layout; + const needsRotation = cards[index].template!.layout !== neutralTemplate; await addCanvasToPdfPage( canvas!, diff --git a/src/utils/prepareTemplateCarousel.ts b/src/utils/prepareTemplateCarousel.ts index bf4cf1c..53ad147 100644 --- a/src/utils/prepareTemplateCarousel.ts +++ b/src/utils/prepareTemplateCarousel.ts @@ -1,9 +1,11 @@ -import { templateType } from "../resourcesTypedef"; +import type { templateType, templateTypeV2 } from "../resourcesTypedef"; import { StaticCanvas, FabricImage } from 'fabric'; import { setTemplateOnCanvases } from "./setTemplate"; +import { setTemplateV2OnCanvases } from "./setTemplateV2"; import { CardData } from "../contexts/fileDropper"; +import { isTemplateV2 } from "./utils"; -export const prepareTemplateCarousel = async (templates: templateType[], img: HTMLImageElement): Promise => { +export const prepareTemplateCarousel = async (templates: (templateType | templateTypeV2)[], img: HTMLImageElement): Promise => { const canvases = []; for (const template of templates) { const canvas = new StaticCanvas(undefined, { @@ -11,7 +13,7 @@ export const prepareTemplateCarousel = async (templates: templateType[], img: HT enableRetinaScaling: false, backgroundColor: 'white', }); - canvas.add((new FabricImage(img))) + canvas.add((new FabricImage(img, { resourceType: "main" }))) const card: CardData = { file: img, canvas, @@ -21,7 +23,11 @@ export const prepareTemplateCarousel = async (templates: templateType[], img: HT originalColors: [], key: 'x', } - await setTemplateOnCanvases([card], template) + if (isTemplateV2(template)) { + await setTemplateV2OnCanvases([card], template) + } else { + await setTemplateOnCanvases([card], template) + } canvases.push(canvas.lowerCanvasEl); } return canvases; diff --git a/src/utils/setTemplate.ts b/src/utils/setTemplate.ts index b2e1142..3af1b36 100644 --- a/src/utils/setTemplate.ts +++ b/src/utils/setTemplate.ts @@ -3,11 +3,8 @@ import { util, Point, Shadow, - loadSVGFromURL, Group, FabricObject, - Color, - Gradient, type Canvas, type SerializedGroupProps, Rect, @@ -15,12 +12,41 @@ import { import { CardData } from '../contexts/fileDropper'; import type { templateType, templateOverlay } from '../resourcesTypedef'; import { processCustomizations } from './processCustomizations'; +import { extractUniqueColorsFromGroup, parseSvg } from './templateHandling'; FabricObject.ownDefaults.originX = 'center'; FabricObject.ownDefaults.originY = 'center'; FabricObject.ownDefaults.objectCaching = false; /* add the ability to parse 'id' to rects */ -Rect.ATTRIBUTE_NAMES = [...Rect.ATTRIBUTE_NAMES, 'id']; +Rect.ATTRIBUTE_NAMES = [...Rect.ATTRIBUTE_NAMES, 'id', 'zaparoo-placeholder', 'zaparoo-scale-strategy']; +FabricObject.customProperties = [ + 'zaparoo-placeholder', + 'id', + 'zaparoo-scale-strategy', + 'original_stroke', + 'original_fill' +]; + +FabricImage.customProperties = [ + 'resourceType', + 'original_stroke', + 'original_fill' +]; + +// declare the methods for typescript +declare module "fabric" { + // to have the properties recognized on the instance and in the constructor + interface FabricObject { + "original_fill": string; + "original_stroke": string; + "zaparoo-placeholder"?: "main"; + "zaparoo-scale-strategy"?: "fit" | "cover"; + } + + interface FabricImage { + "resourceType"?: "main" | "screenshot" | "logo"; + } +} export const scaleImageToOverlayArea = ( template: templateType, @@ -104,51 +130,6 @@ export const scaleImageToOverlayArea = ( mainImage.setCoords(); }; -/** - * extract and normalizes to hex format colors in the objects - * remove opacity from colors and sets it on the objects - * @param group - */ -// TODO: supports gradients and objects with different opacity -const extractUniqueColorsFromGroup = (group: Group): string[] => { - const colors: string[] = []; - group.forEachObject((object) => { - (['stroke', 'fill'] as const).forEach((property) => { - if ( - object[property] && - object[property] !== 'transparent' && - !(object[property] as Gradient<'linear'>).colorStops - ) { - const colorInstance = new Color(object[property] as string); - const hexValue = `#${colorInstance.toHex()}`; - const opacity = colorInstance.getAlpha(); - object[property] = hexValue; - object.set({ - [property]: hexValue, - [`original_${property}`]: hexValue, - }); - object.opacity = opacity; - if (!colors.includes(hexValue)) { - colors.push(hexValue); - } - } - }); - }); - return colors; -}; - -const parseSvg = (url: string): Promise => - loadSVGFromURL(url).then(({ objects }) => { - const nonNullObjects = objects.filter( - (objects) => !!objects, - ) as FabricObject[]; - const group = new Group(nonNullObjects); - extractUniqueColorsFromGroup(group); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return group.toObject(['original_stroke', 'original_fill', 'id']); - }); - const reposition = ( fabricLayer: FabricObject, template: templateType, @@ -169,6 +150,7 @@ export const setTemplateOnCanvases = async ( cards: CardData[], template: templateType, ): Promise => { + console.trace(cards) const { overlay, background, shadow, layout } = template || {}; const [overlayImageSource, backgroundImageSource] = await Promise.all([ overlay && @@ -230,7 +212,11 @@ export const setTemplateOnCanvases = async ( { cssOnly: true }, ); } - const mainImage = canvas.getObjects('image')[0] as FabricImage; + const mainImage = canvas.getObjects('image').find( + (fabricImage) => (fabricImage as FabricImage).resourceType === 'main' + ) as FabricImage; + canvas.remove(...canvas.getObjects()); + canvas.add(mainImage); if (mainImage && shadow) { mainImage.shadow = new Shadow({ ...Shadow.parseShadow(shadow), nonScaling: true }); } diff --git a/src/utils/setTemplateV2.ts b/src/utils/setTemplateV2.ts new file mode 100644 index 0000000..5f3f121 --- /dev/null +++ b/src/utils/setTemplateV2.ts @@ -0,0 +1,224 @@ +import { + FabricImage, + util, + loadSVGFromURL, + Group, + FabricObject, + type Canvas, + Rect, +} from 'fabric'; +import { CardData } from '../contexts/fileDropper'; +import type { templateType, templateTypeV2 } from '../resourcesTypedef'; +import { extractUniqueColorsFromGroup } from './templateHandling'; + +FabricObject.ownDefaults.originX = 'center'; +FabricObject.ownDefaults.originY = 'center'; +FabricObject.ownDefaults.objectCaching = false; +/* add the ability to parse 'id' to rects */ +Rect.ATTRIBUTE_NAMES = [...Rect.ATTRIBUTE_NAMES, 'id', 'zaparoo-placeholder', 'zaparoo-scale-strategy']; +FabricObject.customProperties = [ + 'zaparoo-placeholder', + 'id', + 'zaparoo-scale-strategy', + 'original_stroke', + 'original_fill' +]; + +FabricImage.customProperties = [ + 'resourceType', + 'original_stroke', + 'original_fill' +]; + +// declare the methods for typescript +declare module "fabric" { + // to have the properties recognized on the instance and in the constructor + interface FabricObject { + "original_fill": string; + "original_stroke": string; + "zaparoo-placeholder"?: "main"; + "zaparoo-scale-strategy"?: "fit" | "cover"; + } + + interface FabricImage { + "resourceType"?: "main" | "screenshot" | "logo"; + } +} + + +export const scaleImageToOverlayArea = ( + placeholder: FabricObject, + mainImage: FabricImage, +) => { + + // scale the art to the designed area in the template. to fit + // TODO: add option later for fit or cover + const isRotated = mainImage.angle % 180 !== 0; + const isCover = placeholder["zaparoo-scale-strategy"] === "cover"; + const scaler = isCover ? util.findScaleToCover : util.findScaleToFit; + const scaledOverlay = placeholder._getTransformedDimensions(); + + const scale = scaler( + { + width: isRotated ? mainImage.height : mainImage.width, + height: isRotated ? mainImage.width : mainImage.height, + }, + { + width: scaledOverlay.x, + height: scaledOverlay.y, + }, + ); + + if (isCover) { + const clipPath = new Rect({ width: scaledOverlay.x, height: scaledOverlay.y }); + clipPath.absolutePositioned = true; + mainImage.clipPath = clipPath; + } else { + mainImage.clipPath = undefined + } + + mainImage.set({ + scaleX: scale, + scaleY: scale, + }); + + mainImage.top = placeholder.top; + mainImage.left = placeholder.left; + + if (mainImage.clipPath) { + mainImage.clipPath.left = mainImage.left; + mainImage.clipPath.top = mainImage.top; + } + mainImage.setCoords(); +}; + + + +const parseSvg = (url: string): Promise => + loadSVGFromURL(url).then(({ objects }) => { + const nonNullObjects = objects.filter( + (objects) => !!objects, + ) as FabricObject[]; + const group = new Group(nonNullObjects); + extractUniqueColorsFromGroup(group); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return group; + }); + +const reposition = ( + fabricLayer: FabricObject, + template: templateType, +): void => { + if (template.layout === 'horizontal') { + fabricLayer.left = template.media.width / 2; + fabricLayer.top = template.media.height / 2; + } else { + fabricLayer.left = template.media.height / 2; + fabricLayer.top = template.media.width / 2; + } + fabricLayer.setCoords(); +}; + +export const setTemplateV2OnCanvases = async ( + cards: CardData[], + template: templateTypeV2, +): Promise => { + const { layout, url, parsed, media } = template; + + const templateSource = await (parsed ?? (template.parsed = parseSvg(url))); + const placeholder = templateSource.getObjects().find((obj) => obj["zaparoo-placeholder"] === "main"); + if (placeholder) { + // remove strokewidth so the placeholder can clip the image + placeholder.strokeWidth = 0; + // the placeholder stays with us but we don't want to see it + placeholder.visible = false; + } + // fixme: avoid parsing colors more than once. + const colors = extractUniqueColorsFromGroup(templateSource); + const isHorizontal = layout === 'horizontal'; + const { width, height } = media; + const finalWidth = isHorizontal ? width : height; + const finalHeight = isHorizontal ? height : width; + + for (const card of cards) { + const { canvas } = card; + if (!canvas) { + continue; + } + card.template = template; + // resize only if necessary + if (finalHeight !== canvas.height || finalWidth !== canvas.width) { + canvas.setDimensions( + { + width: finalWidth, + height: finalHeight, + }, + { backstoreOnly: true }, + ); + const clipPath = new Rect(template.media); + clipPath.canvas = canvas as Canvas; + canvas.centerObject(clipPath); + canvas.clipPath = clipPath; + + canvas.setDimensions( + { + width: isHorizontal ? 'var(--cell-width)' : 'auto', + height: isHorizontal ? 'auto' : 'var(--cell-width)', + }, + { cssOnly: true }, + ); + } + // save a reference to the original image + const mainImage = canvas.getObjects('image').find( + (fabricImage) => (fabricImage as FabricImage).resourceType === 'main' + ) as FabricImage; + + // copy the template for this card + const fabricLayer = await templateSource.clone(); + // find out how bit it is naturally + const templateSize = fabricLayer._getTransformedDimensions(); + // scale the overlay asset to fit the designed media ( the card ) + const templateScale = util.findScaleToFit({ + width: templateSize.x, + height: templateSize.y, + }, canvas); + + fabricLayer.scaleX = templateScale; + fabricLayer.scaleY = templateScale; + + // set the overlay of the template in the center of the card + reposition(fabricLayer, template); + + // remove the previous template from the canvas if any. + canvas.remove(...canvas.getObjects()); + canvas.backgroundImage = undefined; + canvas.overlayImage = undefined; + // add the template to the canvas + canvas.add(...fabricLayer.removeAll()); + // find the layer that olds the image. + const placeholder = canvas.getObjects().find((obj) => obj["zaparoo-placeholder"] === "main"); + if (placeholder) { + // add the image on the placeholder + if (mainImage) { + const index = canvas.getObjects().indexOf(placeholder); + canvas.insertAt(index, mainImage); + scaleImageToOverlayArea(placeholder, mainImage); + } + } + + const { clipPath } = canvas; + if (clipPath) { + if (template.layout === 'horizontal') { + clipPath.angle = 0; + } else { + clipPath.angle = 90; + } + reposition(clipPath, template); + } + + canvas.requestRenderAll(); + } + // this could returned by the promise right away + return colors; +}; diff --git a/src/utils/templateHandling.ts b/src/utils/templateHandling.ts new file mode 100644 index 0000000..9a3ba6c --- /dev/null +++ b/src/utils/templateHandling.ts @@ -0,0 +1,56 @@ +import { + loadSVGFromURL, + Group, + FabricObject, + Color, + Gradient, + type SerializedGroupProps, +} from 'fabric'; + +/** + * extract and normalizes to hex format colors in the objects + * remove opacity from colors and sets it on the objects + * @param group + */ +// TODO: supports gradients and objects with different opacity +export const extractUniqueColorsFromGroup = (group: Group): string[] => { + const colors: string[] = []; + group.forEachObject((object) => { + if (!object.visible) { + return; + } + (['stroke', 'fill'] as const).forEach((property) => { + if ( + object[property] && + object[property] !== 'transparent' && + !(object[property] as Gradient<'linear'>).colorStops + ) { + const colorInstance = new Color(object[property] as string); + const hexValue = `#${colorInstance.toHex()}`; + const opacity = colorInstance.getAlpha(); + object[property] = hexValue; + object.set({ + [property]: hexValue, + [`original_${property}`]: hexValue, + }); + object.opacity = opacity; + if (!colors.includes(hexValue)) { + colors.push(hexValue); + } + } + }); + }); + return colors; +}; + +export const parseSvg = (url: string): Promise => + loadSVGFromURL(url).then(({ objects }) => { + const nonNullObjects = objects.filter( + (objects) => !!objects, + ) as FabricObject[]; + const group = new Group(nonNullObjects); + extractUniqueColorsFromGroup(group); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return group.toObject(['original_stroke', 'original_fill', 'id']); + }); \ No newline at end of file diff --git a/src/utils/updateColors.ts b/src/utils/updateColors.ts index 2356e67..5e718a5 100644 --- a/src/utils/updateColors.ts +++ b/src/utils/updateColors.ts @@ -1,6 +1,26 @@ -import { Group } from 'fabric'; +import { FabricObject, Group } from 'fabric'; import { type CardData } from '../contexts/fileDropper'; + +const colorChanger = (object: FabricObject, colors: string[], originalColors: string[]) => { + (['stroke', 'fill'] as const).forEach((property) => { + if (object[property]) { + const objectOriginalColor = object[ + `original_${property}` as keyof typeof object + ] as string; + const originalIndex = originalColors.indexOf(objectOriginalColor); + if ( + originalIndex > -1 && + colors[originalIndex] !== object[property] + ) { + object.set({ + [property]: colors[originalIndex], + }); + } + } + }); +} + export const updateColors = ( cards: CardData[], colors: string[], @@ -14,47 +34,20 @@ export const updateColors = ( card.colors = colors; card.originalColors = originalColors; const { overlayImage, backgroundImage } = canvas; + const objects = canvas.getObjects(); + objects.forEach((object) => { + colorChanger(object, colors, originalColors); + }) if (overlayImage instanceof Group) { overlayImage.forEachObject((object) => { - (['stroke', 'fill'] as const).forEach((property) => { - if (object[property]) { - const objectOriginalColor = object[ - `original_${property}` as keyof typeof object - ] as string; - const originalIndex = originalColors.indexOf(objectOriginalColor); - if ( - originalIndex > -1 && - colors[originalIndex] !== object[property] - ) { - object.set({ - [property]: colors[originalIndex], - }); - canvas.requestRenderAll(); - } - } - }); + colorChanger(object, colors, originalColors); }); } if (backgroundImage instanceof Group) { backgroundImage.forEachObject((object) => { - (['stroke', 'fill'] as const).forEach((property) => { - if (object[property]) { - const objectOriginalColor = object[ - `original_${property}` as keyof typeof object - ] as string; - const originalIndex = originalColors.indexOf(objectOriginalColor); - if ( - originalIndex > -1 && - colors[originalIndex] !== object[property] - ) { - object.set({ - [property]: colors[originalIndex], - }); - canvas.requestRenderAll(); - } - } - }); + colorChanger(object, colors, originalColors); }); } + canvas.requestRenderAll(); }); }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 2c60dd0..b409d26 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,5 @@ +import { templateType, templateTypeV2 } from "../resourcesTypedef"; + export const colorsDiffer = (colorsA: string[], colorsB: string[]): boolean => colorsA.some((color, index) => colorsB[index] !== color); @@ -15,3 +17,5 @@ export const downloadBlob = (blob: Blob, name: string): void => { } export const fromMMtoPxAt72DPI = (mm: number): number => mm / 25.4 * 72; + +export const isTemplateV2 = (t: templateType | templateTypeV2): t is templateTypeV2 => (t as templateTypeV2).version === 2; \ No newline at end of file