🎉 Add .penpot (binfile-v3) support for library

This commit is contained in:
Andrey Antukh 2025-05-15 11:39:34 +02:00
parent 1fea1e8f5b
commit 29d23577d2
20 changed files with 926 additions and 751 deletions

View file

@ -8,33 +8,65 @@
**Breaking changes on penpot library:**
The library entrypoint API object has been changed. From now you start creating a new
build context, from where you can add multiple files and attach media. This change add the
ability to build more than one file at same time and export them in an unique .penpot
file.
```js
const context = penpot.createBuildContext()
context.addFile({name:"aa"})
context.addPage({name:"aa"})
context.closePage()
context.closeFile()
;; barray is instance of Uint8Array
const barray = penpot.exportAsBytes(context);
```
The previous `file.export()` method has been removed and several alternatives are
added as first level functions on penpot library API entrypoint:
- `exportAsBytes(BuildContext context) -> Promise<Uint8Array>`
- `exportAsBlob(BuildContext context) -> Promise<Blob>`
- `exportStream(BuildContext context, WritableStream stream) -> Promise<Void>`
The stream variant allows writting data as it is generated to the stream, without the need
to store the generated output entirelly in the memory.
There are also relevant semantic changes in how components should be created: this
refactor removes all notions of the old components (v1). Since v2, the shapes that are
part of a component live on a page. So, from now on, to create a component, you should
first create a frame, then add shapes and/or groups to that frame, and then create a
component by declaring that frame as the component root.
A non exhaustive list of changes:
- Change the signature of the `addPage` method: it now accepts an object (as a single argument) where you can pass `id`,
`name`, and `background` props (instead of the previous positional arguments)
- Rename the `file.createRect` method to `file.addRect`
- Rename the `file.createCircle` method to `file.addCircle`
- Rename the `file.createPath` method to `file.addPath`
- Rename the `file.createText` method to `file.addText`
- Rename `file.startComponent` to `file.addComponent` (to preserve the naming style)
- Rename `file.createComponentInstance` to `file.addComponentInstance` (to preserve the naming style)
- Rename `file.lookupShape` to `file.getShape`
- Rename `file.asMap` to `file.toMap`
- Remove `file.updateLibraryColor` (use `file.addLibraryColor` if you just need to replace a color)
- Remove `file.deleteLibraryColor` (this library is intended to build files)
- Remove `file.updateLibraryTypography` (use `file.addLibraryTypography` if you just need to replace a typography)
- Remove `file.deleteLibraryTypography` (this library is intended to build files)
- Remove `file.add/update/deleteLibraryMedia` (they are no longer supported by Penpot and have been replaced by components)
- Remove `file.deleteObject` (this library is intended to build files)
- Remove `file.updateObject` (this library is intended to build files)
- Remove `file.finishComponent` (it is no longer necessary; see below for more details on component creation changes)
- Change the `file.getCurrentPageId` function to a read-only `file.currentPageId` property
- Add `file.currentFrameId` read-only property
- Add `file.lastId` read-only property
- Rename the `createRect` method to `addRect`
- Rename the `createCircle` method to `addCircle`
- Rename the `createPath` method to `addPath`
- Rename the `createText` method to `addText`
- Rename the `addArtboard` method to `addBoard`
- Rename `startComponent` to `addComponent` (to preserve the naming style)
- Rename `createComponentInstance` to `addComponentInstance` (to preserve the naming style)
- Remove `lookupShape`
- Remove `asMap`
- Remove `updateLibraryColor` (use `addLibraryColor` if you just need to replace a color)
- Remove `deleteLibraryColor` (this library is intended to build files)
- Remove `updateLibraryTypography` (use `addLibraryTypography` if you just need to replace a typography)
- Remove `deleteLibraryTypography` (this library is intended to build files)
- Remove `add/update/deleteLibraryMedia` (they are no longer supported by Penpot and have been replaced by components)
- Remove `deleteObject` (this library is intended to build files)
- Remove `updateObject` (this library is intended to build files)
- Remove `finishComponent` (it is no longer necessary; see below for more details on component creation changes)
There are also relevant semantic changes in how components should be created: this refactor removes
all notions of the old components (v1). Since v2, the shapes that are part of a component live on a
page. So, from now on, to create a component, you should first create a frame, then add shapes
and/or groups to that frame, and then create a component by declaring that frame as the component
root.
- Change the `getCurrentPageId` function to a read-only `currentPageId` property
- Add `currentFileId` read-only property
- Add `currentFrameId` read-only property
- Add `lastId` read-only property
### :heart: Community contributions (Thank you!)

View file

@ -18,6 +18,7 @@
[app.common.files.migrations :as-alias fmg]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.media :as cmedia]
[app.common.schema :as sm]
[app.common.thumbnails :as cth]
[app.common.types.color :as ctcl]
@ -73,7 +74,7 @@
[:size ::sm/int]
[:content-type :string]
[:bucket [::sm/one-of {:format :string} sto/valid-buckets]]
[:hash :string]])
[:hash {:optional true} :string]])
(def ^:private schema:file-thumbnail
[:map {:title "FileThumbnail"}
@ -88,13 +89,19 @@
ctf/schema:file
[:map [:options {:optional true} ctf/schema:options]]])
;; --- HELPERS
(defn- default-now
[o]
(or o (dt/now)))
;; --- ENCODERS
(def encode-file
(sm/encoder schema:file sm/json-transformer))
(def encode-page
(sm/encoder ::ctp/page sm/json-transformer))
(sm/encoder ctp/schema:page sm/json-transformer))
(def encode-shape
(sm/encoder ::cts/shape sm/json-transformer))
@ -129,7 +136,7 @@
(sm/decoder schema:manifest sm/json-transformer))
(def decode-media
(sm/decoder ::ctf/media sm/json-transformer))
(sm/decoder ctf/schema:media sm/json-transformer))
(def decode-component
(sm/decoder ::ctc/component sm/json-transformer))
@ -229,27 +236,13 @@
:always
(bfc/clean-file-features))))))
(defn- resolve-extension
[mtype]
(case mtype
"image/png" ".png"
"image/jpeg" ".jpg"
"image/gif" ".gif"
"image/svg+xml" ".svg"
"image/webp" ".webp"
"font/woff" ".woff"
"font/woff2" ".woff2"
"font/ttf" ".ttf"
"font/otf" ".otf"
"application/octet-stream" ".bin"))
(defn- export-storage-objects
[{:keys [::output] :as cfg}]
(let [storage (sto/resolve cfg)]
(doseq [id (-> bfc/*state* deref :storage-objects not-empty)]
(let [sobject (sto/get-object storage id)
smeta (meta sobject)
ext (resolve-extension (:content-type smeta))
ext (cmedia/mtype->extension (:content-type smeta))
path (str "objects/" id ".json")
params (-> (meta sobject)
(assoc :id (:id sobject))
@ -574,7 +567,14 @@
(let [object (->> (read-entry input entry)
(decode-media)
(validate-media))
object (assoc object :file-id file-id)]
object (-> object
(assoc :file-id file-id)
(update :created-at default-now)
(update :modified-at default-now)
;; FIXME: this is set default to true for
;; setting a value, this prop is no longer
;; relevant;
(assoc :is-local true))]
(if (= id (:id object))
(conj result object)
result)))
@ -800,7 +800,7 @@
:expected-id (str id)
:found-id (str (:id object))))
(let [ext (resolve-extension (:content-type object))
(let [ext (cmedia/mtype->extension (:content-type object))
path (str "objects/" id ext)
content (->> path
(get-zip-entry input)
@ -814,13 +814,14 @@
:expected-size (:size object)
:found-size (sto/get-size content)))
(when (not= (:hash object) (sto/get-hash content))
(ex/raise :type :validation
:code :inconsistent-penpot-file
:hint "found corrupted storage object: hash does not match"
:path path
:expected-hash (:hash object)
:found-hash (sto/get-hash content)))
(when-let [hash (get object :hash)]
(when (not= hash (sto/get-hash content))
(ex/raise :type :validation
:code :inconsistent-penpot-file
:hint "found corrupted storage object: hash does not match"
:path path
:expected-hash (:hash object)
:found-hash (sto/get-hash content))))
(let [params (-> object
(dissoc :id :size)

View file

@ -1712,6 +1712,7 @@
[{:fill-image
{:id (:id fmedia)
:name "test"
:mtype "image/jpeg"
:width 200
:height 200}}]]

View file

@ -13,17 +13,12 @@
[app.common.features :as cfeat]
[app.common.files.changes :as ch]
[app.common.files.migrations :as fmig]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.schema :as sm]
[app.common.svg :as csvg]
[app.common.types.color :as types.color]
[app.common.types.component :as types.component]
[app.common.types.components-list :as types.components-list]
[app.common.types.container :as types.container]
[app.common.types.file :as types.file]
[app.common.types.page :as types.page]
[app.common.types.pages-list :as types.pages-list]
[app.common.types.shape :as types.shape]
[app.common.types.typography :as types.typography]
[app.common.uuid :as uuid]
@ -37,41 +32,36 @@
(def ^:private conjv (fnil conj []))
(def ^:private conjs (fnil conj #{}))
(defn default-uuid
(defn- default-uuid
[v]
(or v (uuid/next)))
(defn- track-used-name
[file name]
(let [container-id (::current-page-id file)]
(update-in file [::unames container-id] conjs name)))
[state name]
(let [container-id (::current-page-id state)]
(update-in state [::unames container-id] conjs name)))
(defn- commit-change
[file change & {:keys [add-container]
:or {add-container false}}]
[state change & {:keys [add-container]}]
(let [file-id (get state ::current-file-id)]
(assert (uuid? file-id) "no current file id")
(let [change (cond-> change
add-container
(assoc :page-id (::current-page-id file)
:frame-id (::current-frame-id file)))]
(-> file
(update ::changes conjv change)
(update :data ch/process-changes [change] false))))
(defn- lookup-objects
[file]
(dm/get-in file [:data :pages-index (::current-page-id file) :objects]))
(let [change (cond-> change
add-container
(assoc :page-id (::current-page-id state)
:frame-id (::current-frame-id state)))]
(update-in state [::files file-id :data] ch/process-changes [change] false))))
(defn- commit-shape
[file shape]
[state shape]
(let [parent-id
(-> file ::parent-stack peek)
(-> state ::parent-stack peek)
frame-id
(::current-frame-id file)
(get state ::current-frame-id)
page-id
(::current-page-id file)
(get state ::current-page-id)
change
{:type :add-obj
@ -82,39 +72,31 @@
:frame-id frame-id
:page-id page-id}]
(-> file
(-> state
(commit-change change)
(track-used-name (:name shape)))))
(defn- generate-name
[type data]
(if (= type :svg-raw)
(let [tag (dm/get-in data [:content :tag])]
(str "svg-" (cond (string? tag) tag
(keyword? tag) (d/name tag)
(nil? tag) "node"
:else (str tag))))
(str/capital (d/name type))))
(defn- unique-name
[name file]
(let [container-id (::current-page-id file)
unames (dm/get-in file [:unames container-id])]
[name state]
(let [container-id (::current-page-id state)
unames (dm/get-in state [:unames container-id])]
(d/unique-name name (or unames #{}))))
(defn- clear-names [file]
(dissoc file ::unames))
(defn- assign-name
(defn- assign-shape-name
"Given a tag returns its layer name"
[data file type]
(cond-> data
(nil? (:name data))
(assoc :name (generate-name type data))
[shape state]
(cond-> shape
(nil? (:name shape))
(assoc :name (let [type (get shape :type)]
(case type
:frame "Board"
(str/capital (d/name type)))))
:always
(update :name unique-name file)))
(update :name unique-name state)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMAS
@ -135,16 +117,25 @@
(def decode-library-typography
(sm/decode-fn types.typography/schema:typography sm/json-transformer))
(def decode-component
(sm/decode-fn types.component/schema:component sm/json-transformer))
(def schema:add-component
[:map
[:component-id ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:name {:optional true} ::sm/text]
[:path {:optional true} ::sm/text]])
(def ^:private check-add-component
(sm/check-fn schema:add-component))
(def decode-add-component
(sm/decode-fn schema:add-component sm/json-transformer))
(def schema:add-component-instance
[:map
[:component-id ::sm/uuid]
[:x ::sm/safe-number]
[:y ::sm/safe-number]])
[:file-id {:optional true} ::sm/uuid]])
(def check-add-component-instance
(def ^:private check-add-component-instance
(sm/check-fn schema:add-component-instance))
(def decode-add-component-instance
@ -158,37 +149,76 @@
(def decode-add-bool
(sm/decode-fn schema:add-bool sm/json-transformer))
(def check-add-bool
(def ^:private check-add-bool
(sm/check-fn schema:add-bool))
(def schema:add-file-media
[:map
[:id {:optional true} ::sm/uuid]
[:name ::sm/text]
[:width ::sm/int]
[:height ::sm/int]])
(def decode-add-file-media
(sm/decode-fn schema:add-file-media sm/json-transformer))
(def check-add-file-media
(sm/check-fn schema:add-file-media))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn lookup-shape [file shape-id]
(-> (lookup-objects file)
(get shape-id)))
(defn create-state
[]
{})
(defn get-current-page
[file]
(let [page-id (::current-page-id file)]
(dm/get-in file [:data :pages-index page-id])))
[state]
(let [file-id (get state ::current-file-id)
page-id (get state ::current-page-id)]
(defn create-file
[params]
(assert (uuid? file-id) "expected current-file-id to be assigned")
(assert (uuid? page-id) "expected current-page-id to be assigned")
(dm/get-in state [::files file-id :data :pages-index page-id])))
(defn get-current-objects
[state]
(-> (get-current-page state)
(get :objects)))
(defn get-shape
[state shape-id]
(-> (get-current-objects state)
(get shape-id)))
(defn add-file
[state params]
(let [params (-> params
(assoc :features cfeat/default-features)
(assoc :migrations fmig/available-migrations))]
(types.file/make-file params :create-page false)))
(assoc :migrations fmig/available-migrations))
file (types.file/make-file params :create-page false)]
(-> state
(update ::files assoc (:id file) file)
(assoc ::current-file-id (:id file)))))
(declare close-page)
(defn close-file
[state]
(let [state (-> state
(close-page)
(dissoc ::current-file-id))]
state))
(defn add-page
[file params]
[state params]
(let [page (-> (types.page/make-empty-page params)
(types.page/check-page))
change {:type :add-page
:page page}]
(-> file
(-> state
(commit-change change)
;; Current page being edited
@ -203,96 +233,96 @@
;; Last object id added
(assoc ::last-id nil))))
(defn close-page [file]
(-> file
(defn close-page [state]
(-> state
(dissoc ::current-page-id)
(dissoc ::parent-stack)
(dissoc ::last-id)
(clear-names)))
(defn add-artboard
[file data]
(defn add-board
[state params]
(let [{:keys [id] :as shape}
(-> data
(-> params
(update :id default-uuid)
(assoc :type :frame)
(assign-name file :frame)
(assign-shape-name state)
(types.shape/setup-shape)
(types.shape/check-shape))]
(-> file
(-> state
(commit-shape shape)
(update ::parent-stack conjv id)
(assoc ::current-frame-id id)
(assoc ::last-id id))))
(defn close-artboard
[file]
(let [parent-id (-> file ::parent-stack peek)
parent (lookup-shape file parent-id)]
(-> file
(defn close-board
[state]
(let [parent-id (-> state ::parent-stack peek)
parent (get-shape state parent-id)]
(-> state
(assoc ::current-frame-id (or (:frame-id parent) root-id))
(update ::parent-stack pop))))
(defn add-group
[file params]
[state params]
(let [{:keys [id] :as shape}
(-> params
(update :id default-uuid)
(assoc :type :group)
(assign-name file :group)
(assign-shape-name state)
(types.shape/setup-shape)
(types.shape/check-shape))]
(-> file
(-> state
(commit-shape shape)
(assoc ::last-id id)
(update ::parent-stack conjv id))))
(defn close-group
[file]
(let [group-id (-> file :parent-stack peek)
group (lookup-shape file group-id)
[state]
(let [group-id (-> state :parent-stack peek)
group (get-shape state group-id)
children (->> (get group :shapes)
(into [] (keep (partial lookup-shape file)))
(into [] (keep (partial get-shape state)))
(not-empty))]
(assert (some? children) "group expect to have at least 1 children")
(let [file (if (:masked-group group)
(let [mask (first children)
change {:type :mod-obj
:id group-id
:operations
[{:type :set :attr :x :val (-> mask :selrect :x) :ignore-touched true}
{:type :set :attr :y :val (-> mask :selrect :y) :ignore-touched true}
{:type :set :attr :width :val (-> mask :selrect :width) :ignore-touched true}
{:type :set :attr :height :val (-> mask :selrect :height) :ignore-touched true}
{:type :set :attr :flip-x :val (-> mask :flip-x) :ignore-touched true}
{:type :set :attr :flip-y :val (-> mask :flip-y) :ignore-touched true}
{:type :set :attr :selrect :val (-> mask :selrect) :ignore-touched true}
{:type :set :attr :points :val (-> mask :points) :ignore-touched true}]}]
(commit-change file change :add-container true))
(let [group (gsh/update-group-selrect group children)
change {:type :mod-obj
:id group-id
:operations
[{:type :set :attr :selrect :val (:selrect group) :ignore-touched true}
{:type :set :attr :points :val (:points group) :ignore-touched true}
{:type :set :attr :x :val (-> group :selrect :x) :ignore-touched true}
{:type :set :attr :y :val (-> group :selrect :y) :ignore-touched true}
{:type :set :attr :width :val (-> group :selrect :width) :ignore-touched true}
{:type :set :attr :height :val (-> group :selrect :height) :ignore-touched true}]}]
(let [state (if (:masked-group group)
(let [mask (first children)
change {:type :mod-obj
:id group-id
:operations
[{:type :set :attr :x :val (-> mask :selrect :x) :ignore-touched true}
{:type :set :attr :y :val (-> mask :selrect :y) :ignore-touched true}
{:type :set :attr :width :val (-> mask :selrect :width) :ignore-touched true}
{:type :set :attr :height :val (-> mask :selrect :height) :ignore-touched true}
{:type :set :attr :flip-x :val (-> mask :flip-x) :ignore-touched true}
{:type :set :attr :flip-y :val (-> mask :flip-y) :ignore-touched true}
{:type :set :attr :selrect :val (-> mask :selrect) :ignore-touched true}
{:type :set :attr :points :val (-> mask :points) :ignore-touched true}]}]
(commit-change state change :add-container true))
(let [group (gsh/update-group-selrect group children)
change {:type :mod-obj
:id group-id
:operations
[{:type :set :attr :selrect :val (:selrect group) :ignore-touched true}
{:type :set :attr :points :val (:points group) :ignore-touched true}
{:type :set :attr :x :val (-> group :selrect :x) :ignore-touched true}
{:type :set :attr :y :val (-> group :selrect :y) :ignore-touched true}
{:type :set :attr :width :val (-> group :selrect :width) :ignore-touched true}
{:type :set :attr :height :val (-> group :selrect :height) :ignore-touched true}]}]
(commit-change file change :add-container true)))]
(update file ::parent-stack pop))))
(commit-change state change :add-container true)))]
(update state ::parent-stack pop))))
(defn add-bool
[file params]
[state params]
(let [{:keys [group-id type]}
(check-add-bool params)
group
(lookup-shape file group-id)
(get-shape state group-id)
children
(->> (get group :shapes)
@ -300,7 +330,7 @@
(assert (some? children) "expect group to have at least 1 element")
(let [objects (lookup-objects file)
(let [objects (get-current-objects state)
bool (-> group
(assoc :type :bool)
(gsh/update-bool objects))
@ -317,101 +347,102 @@
{:type :set :attr :width :val (-> bool :selrect :width) :ignore-touched true}
{:type :set :attr :height :val (-> bool :selrect :height) :ignore-touched true}]}]
(-> file
(-> state
(commit-change change :add-container true)
(assoc ::last-id group-id)))))
(defn add-shape
[file params]
[state params]
(let [obj (-> params
(d/update-when :svg-attrs csvg/attrs->props)
(types.shape/setup-shape)
(assign-name file :type))]
(-> file
(assign-shape-name state))]
(-> state
(commit-shape obj)
(assoc ::last-id (:id obj)))))
(defn add-library-color
[file color]
[state color]
(let [color (-> color
(update :opacity d/nilv 1)
(update :id default-uuid)
(types.color/check-library-color color))
change {:type :add-color
:color color}]
(-> file
(-> state
(commit-change change)
(assoc ::last-id (:id color)))))
(defn add-library-typography
[file typography]
[state typography]
(let [typography (-> typography
(update :id default-uuid)
(d/without-nils))
change {:type :add-typography
:id (:id typography)
:typography typography}]
(-> file
(-> state
(commit-change change)
(assoc ::last-id (:id typography)))))
(defn add-component
[file params]
(let [change1 {:type :add-component
:id (or (:id params) (uuid/next))
:name (:name params)
:path (:path params)
:main-instance-id (:main-instance-id params)
:main-instance-page (:main-instance-page params)}
[state params]
(let [{:keys [component-id file-id name path]}
(check-add-component params)
comp-id (get change1 :id)
change2 {:type :mod-obj
:id (:main-instance-id params)
:operations
[{:type :set :attr :component-root :val true}
{:type :set :attr :component-id :val comp-id}
{:type :set :attr :component-file :val (:id file)}]}]
(-> file
(commit-change change1)
(commit-change change2)
(assoc ::last-id comp-id)
(assoc ::current-frame-id comp-id))))
(defn add-component-instance
[{:keys [id data] :as file} params]
(let [{:keys [component-id x y]}
(check-add-component-instance params)
component
(types.components-list/get-component data component-id)
frame-id
(get state ::current-frame-id)
page-id
(get file ::current-page-id)]
(get state ::current-page-id)
(assert (uuid? page-id) "page-id is expected to be set")
(assert (uuid? component) "component is expected to exist")
component-id
(or component-id (uuid/next))
;; FIXME: this should be on files and not in pages-list
(let [page (types.pages-list/get-page (:data file) page-id)
pos (gpt/point x y)
change1
(d/without-nils
{:type :add-component
:id component-id
:name (or name "anonmous")
:path path
:main-instance-id frame-id
:main-instance-page page-id})
[shape shapes]
(types.container/make-component-instance page component id pos)
change2
{:type :mod-obj
:id frame-id
:operations
[{:type :set :attr :component-root :val true}
{:type :set :attr :component-id :val component-id}
{:type :set :attr :component-file :val file-id}]}]
file
(reduce #(commit-change %1
{:type :add-obj
:id (:id %2)
:page-id (:id page)
:parent-id (:parent-id %2)
:frame-id (:frame-id %2)
:ignore-touched true
:obj %2})
file
shapes)]
(-> state
(commit-change change1)
(commit-change change2))))
(assoc file ::last-id (:id shape)))))
(defn add-component-instance
[state params]
(let [{:keys [component-id file-id]}
(check-add-component-instance params)
file-id
(or file-id (get state ::current-file-id))
frame-id
(get state ::current-frame-id)
change
{:type :mod-obj
:id frame-id
:operations
[{:type :set :attr :component-root :val false}
{:type :set :attr :component-id :val component-id}
{:type :set :attr :component-file :val file-id}]}]
(commit-change state change)))
(defn delete-shape
[file id]
@ -423,10 +454,12 @@
:id id}))
(defn update-shape
[file shape-id f]
(let [page-id (::current-page-id file)
objects (lookup-objects file)
[state shape-id f]
(let [page-id (get state ::current-page-id)
objects (get-current-objects state)
old-shape (get objects shape-id)
new-shape (f old-shape)
attrs (d/concat-set
(keys old-shape)
@ -440,7 +473,7 @@
changes
(conj changes {:type :set :attr attr :val new-val :ignore-touched true}))))]
(-> file
(-> state
(commit-change
{:type :mod-obj
:operations (reduce generate-operation [] attrs)
@ -449,12 +482,12 @@
(assoc ::last-id shape-id))))
(defn add-guide
[file guide]
[state guide]
(let [guide (cond-> guide
(nil? (:id guide))
(assoc :id (uuid/next)))
page-id (::current-page-id file)]
(-> file
page-id (::current-page-id state)]
(-> state
(commit-change
{:type :set-guide
:page-id page-id
@ -463,24 +496,54 @@
(assoc ::last-id (:id guide)))))
(defn delete-guide
[file id]
(let [page-id (::current-page-id file)]
(commit-change file
[state id]
(let [page-id (::current-page-id state)]
(commit-change state
{:type :set-guide
:page-id page-id
:id id
:params nil})))
(defn update-guide
[file guide]
(let [page-id (::current-page-id file)]
(commit-change file
[state guide]
(let [page-id (::current-page-id state)]
(commit-change state
{:type :set-guide
:page-id page-id
:id (:id guide)
:params guide})))
(defn strip-image-extension [filename]
(let [image-extensions-re #"(\.png)|(\.jpg)|(\.jpeg)|(\.webp)|(\.gif)|(\.svg)$"]
(str/replace filename image-extensions-re "")))
(defrecord BlobWrapper [mtype size blob])
(defn add-file-media
[state params blob]
(assert (instance? BlobWrapper blob) "expect blob to be wrapped")
(let [media-id
(uuid/next)
file-id
(get state ::current-file-id)
{:keys [id width height name]}
(-> params
(update :id default-uuid)
(check-add-file-media params))]
(-> state
(update ::blobs assoc media-id blob)
(update ::media assoc media-id
{:id media-id
:bucket "file-media-object"
:content-type (get blob :mtype)
:size (get blob :size)})
(update ::file-media assoc id
{:id id
:name name
:width width
:height height
:file-id file-id
:media-id media-id
:mtype (get blob :mtype)})
(assoc ::last-id id))))

View file

@ -310,12 +310,12 @@
[:add-media
[:map {:title "AddMediaChange"}
[:type [:= :add-media]]
[:object ::ctf/media-object]]]
[:object ctf/schema:media]]]
[:mod-media
[:map {:title "ModMediaChange"}
[:type [:= :mod-media]]
[:object ::ctf/media-object]]]
[:object ctf/schema:media]]]
[:del-media
[:map {:title "DelMediaChange"}

View file

@ -5,8 +5,8 @@
;; Copyright (c) KALEIDOS INC
(ns app.common.media
"Media assets helpers (images, fonts, etc)"
(:require
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
;; We have added ".ttf" as string to solve a problem with chrome input selector
@ -48,38 +48,28 @@
(defn mtype->extension [mtype]
;; https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
(case mtype
"image/apng" ".apng"
"image/avif" ".avif"
"image/gif" ".gif"
"image/jpeg" ".jpg"
"image/png" ".png"
"image/svg+xml" ".svg"
"image/webp" ".webp"
"application/zip" ".zip"
"application/penpot" ".penpot"
"application/pdf" ".pdf"
"text/plain" ".txt"
"image/apng" ".apng"
"image/avif" ".avif"
"image/gif" ".gif"
"image/jpeg" ".jpg"
"image/png" ".png"
"image/svg+xml" ".svg"
"image/webp" ".webp"
"application/zip" ".zip"
"application/penpot" ".penpot"
"application/pdf" ".pdf"
"text/plain" ".txt"
"font/woff" ".woff"
"font/woff2" ".woff2"
"font/ttf" ".ttf"
"font/otf" ".otf"
"application/octet-stream" ".bin"
nil))
(s/def ::id uuid?)
(s/def ::name string?)
(s/def ::width number?)
(s/def ::height number?)
(s/def ::created-at inst?)
(s/def ::modified-at inst?)
(s/def ::mtype string?)
(s/def ::uri string?)
(s/def ::media-object
(s/keys :req-un [::id
::name
::width
::height
::mtype
::created-at
::modified-at
::uri]))
(defn strip-image-extension
[filename]
(let [image-extensions-re #"(\.png)|(\.jpg)|(\.jpeg)|(\.webp)|(\.gif)|(\.svg)$"]
(str/replace filename image-extensions-re "")))
(defn parse-font-weight
[variant]

View file

@ -54,13 +54,13 @@
::oapi/type "integer"
::oapi/format "int64"}}))
(def schema:image-color
(def schema:image
[:map {:title "ImageColor"}
[:name {:optional true} :string]
[:width ::sm/int]
[:height ::sm/int]
[:mtype {:optional true} [:maybe :string]]
[:mtype ::sm/text]
[:id ::sm/uuid]
[:name {:optional true} ::sm/text]
[:keep-aspect-ratio {:optional true} :boolean]])
(def gradient-types
@ -93,7 +93,7 @@
[:ref-id {:optional true} ::sm/uuid]
[:ref-file {:optional true} ::sm/uuid]
[:gradient {:optional true} [:maybe schema:gradient]]
[:image {:optional true} [:maybe schema:image-color]]
[:image {:optional true} [:maybe schema:image]]
[:plugin-data {:optional true} ::ctpg/plugin-data]])
(def schema:color
@ -106,7 +106,7 @@
[:opacity {:optional true} [:maybe ::sm/safe-number]]
[:color {:optional true} [:maybe schema:rgb-color]]
[:gradient {:optional true} [:maybe schema:gradient]]
[:image {:optional true} [:maybe schema:image-color]]]
[:image {:optional true} [:maybe schema:image]]]
[::sm/contains-any {:strict true} [:color :gradient :image]]])
;; Same as color but with :id prop required
@ -115,9 +115,10 @@
(sm/required-keys schema:color-attrs [:id])
[::sm/contains-any {:strict true} [:color :gradient :image]]])
;; FIXME: revisit if we really need this all registers
(sm/register! ::color schema:color)
(sm/register! ::gradient schema:gradient)
(sm/register! ::image-color schema:image-color)
(sm/register! ::image-color schema:image)
(sm/register! ::recent-color schema:recent-color)
(sm/register! ::color-attrs schema:color-attrs)

View file

@ -38,18 +38,18 @@
(def schema:media
"A schema that represents the file media object"
[:map {:title "FileMediaObject"}
[:map {:title "FileMedia"}
[:id ::sm/uuid]
[:created-at ::sm/inst]
[:created-at {:optional true} ::sm/inst]
[:deleted-at {:optional true} ::sm/inst]
[:name :string]
[:width ::sm/safe-int]
[:height ::sm/safe-int]
[:mtype :string]
[:file-id {:optional true} ::sm/uuid]
[:media-id ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:thumbnail-id {:optional true} ::sm/uuid]
[:is-local :boolean]])
[:is-local {:optional true} :boolean]])
(def schema:colors
[:map-of {:gen/max 5} ::sm/uuid ::ctc/color])
@ -102,7 +102,6 @@
(sm/register! ::media schema:media)
(sm/register! ::colors schema:colors)
(sm/register! ::typographies schema:typographies)
(sm/register! ::media-object schema:media)
(def check-file
(sm/check-fn schema:file :hint "check error on validating file"))
@ -110,7 +109,7 @@
(def check-file-data
(sm/check-fn schema:data))
(def check-media-object
(def check-file-media
(sm/check-fn schema:media))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -34,41 +34,35 @@
(defn export-files
[files format]
(dm/assert!
"expected valid files param"
(check-export-files files))
(assert (contains? valid-formats format)
"expected valid export format")
(dm/assert!
"expected valid format"
(contains? valid-formats format))
(let [files (check-export-files files)]
(ptk/reify ::export-files
ptk/WatchEvent
(watch [_ state _]
(let [features (get state :features)
team-id (:current-team-id state)
evname (if (= format :legacy-zip)
"export-standard-files"
"export-binary-files")]
(rx/merge
(rx/of (ptk/event ::ev/event {::ev/name evname
::ev/origin "dashboard"
:format format
:num-files (count files)}))
(->> (rx/from files)
(rx/mapcat
(fn [file]
(->> (rp/cmd! :has-file-libraries {:file-id (:id file)})
(rx/map #(assoc file :has-libraries %)))))
(rx/reduce conj [])
(rx/map (fn [files]
(modal/show
{:type ::export-files
:features features
:team-id team-id
:files files
:format format})))))))))
(ptk/reify ::export-files
ptk/WatchEvent
(watch [_ state _]
(let [team-id (get state :current-team-id)
evname (if (= format :legacy-zip)
"export-standard-files"
"export-binary-files")]
(rx/merge
(rx/of (ptk/event ::ev/event {::ev/name evname
::ev/origin "dashboard"
:format format
:num-files (count files)}))
(->> (rx/from files)
(rx/mapcat
(fn [file]
(->> (rp/cmd! :has-file-libraries {:file-id (:id file)})
(rx/map #(assoc file :has-libraries %)))))
(rx/reduce conj [])
(rx/map (fn [files]
(modal/show
{:type ::export-files
:team-id team-id
:files files
:format format}))))))))))
;;;;;;;;;;;;;;;;;;;;;;
;; Team Request

View file

@ -254,7 +254,7 @@
(defn add-media
[media]
(let [media (ctf/check-media-object media)]
(let [media (ctf/check-file-media media)]
(ptk/reify ::add-media
ev/Event
(-data [_] media)

View file

@ -10,11 +10,11 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.builder :as fb]
[app.common.files.changes-builder :as pcb]
[app.common.files.shapes-builder :as sb]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.media :as media]
[app.common.schema :as sm]
[app.common.types.container :as ctn]
[app.common.types.shape :as cts]
@ -137,7 +137,7 @@
(= (.-type blob) "image/svg+xml")))
(prepare-blob [blob]
(let [name (or name (if (dmm/file? blob) (fb/strip-image-extension (.-name blob)) "blob"))]
(let [name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob"))]
{:file-id file-id
:name name
:is-local local?

View file

@ -27,7 +27,8 @@
(t/is (some? data))))
(t/testing "Add empty page (only root-frame)"
(let [page (-> (fb/create-file {:name "Test"})
(let [page (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/get-current-page))
@ -36,18 +37,19 @@
(t/is (some? data))))
(t/testing "Create simple shape on root"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-shape
{:type :rect
:x 0
:y 0
:width 100
:height 100}))
page (fb/get-current-page file)
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-shape
{:type :rect
:x 0
:y 0
:width 100
:height 100}))
page (fb/get-current-page state)
data (-> (sd/make-snap-data)
(sd/add-page page))
data (-> (sd/make-snap-data)
(sd/add-page page))
result-x (sd/query data (:id page) uuid/zero :x [0 100])]
@ -66,17 +68,18 @@
(t/is (= (first (nth result-x 2)) 100))))
(t/testing "Add page with single empty frame"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-artboard
{:x 0
:y 0
:width 100
:height 100})
(fb/close-artboard))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-board
{:x 0
:y 0
:width 100
:height 100})
(fb/close-board))
frame-id (::fb/last-id file)
page (fb/get-current-page file)
frame-id (::fb/last-id state)
page (fb/get-current-page state)
;; frame-id (::fb/last-id file)
data (-> (sd/make-snap-data)
@ -91,26 +94,27 @@
(t/testing "Add page with some shapes inside frames"
(with-redefs [uuid/next (get-mocked-uuid)]
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-artboard
{:x 0
:y 0
:width 100
:height 100}))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-board
{:x 0
:y 0
:width 100
:height 100}))
frame-id (::fb/last-id file)
frame-id (::fb/last-id state)
file (-> file
(fb/add-shape
{:type :rect
:x 25
:y 25
:width 50
:height 50})
(fb/close-artboard))
state (-> state
(fb/add-shape
{:type :rect
:x 25
:y 25
:width 50
:height 50})
(fb/close-board))
page (fb/get-current-page file)
page (fb/get-current-page state)
data (-> (sd/make-snap-data)
(sd/add-page page))
@ -123,21 +127,21 @@
(t/is (= (count result-frame-x) 5)))))
(t/testing "Add a global guide"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-guide {:position 50 :axis :x})
(fb/add-artboard {:x 200 :y 200 :width 100 :height 100})
(fb/close-artboard))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-guide {:position 50 :axis :x})
(fb/add-board {:x 200 :y 200 :width 100 :height 100})
(fb/close-board))
frame-id (::fb/last-id file)
page (fb/get-current-page file)
frame-id (::fb/last-id state)
page (fb/get-current-page state)
;; frame-id (::fb/last-id file)
data (-> (sd/make-snap-data)
(sd/add-page page))
data (-> (sd/make-snap-data)
(sd/add-page page))
result-zero-x (sd/query data (:id page) uuid/zero :x [0 100])
result-zero-y (sd/query data (:id page) uuid/zero :y [0 100])
result-zero-x (sd/query data (:id page) uuid/zero :x [0 100])
result-zero-y (sd/query data (:id page) uuid/zero :y [0 100])
result-frame-x (sd/query data (:id page) frame-id :x [0 100])
result-frame-y (sd/query data (:id page) frame-id :y [0 100])]
@ -151,17 +155,18 @@
(t/is (= (count result-frame-y) 0))))
(t/testing "Add a frame guide"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-artboard {:x 200 :y 200 :width 100 :height 100})
(fb/close-artboard))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-board {:x 200 :y 200 :width 100 :height 100})
(fb/close-board))
frame-id (::fb/last-id file)
frame-id (::fb/last-id state)
file (-> file
(fb/add-guide {:position 50 :axis :x :frame-id frame-id}))
state (-> state
(fb/add-guide {:position 50 :axis :x :frame-id frame-id}))
page (fb/get-current-page file)
page (fb/get-current-page state)
data (-> (sd/make-snap-data)
(sd/add-page page))
@ -182,26 +187,26 @@
(t/deftest test-update-index
(t/testing "Create frame on root and then remove it."
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-artboard
{:x 0
:y 0
:width 100
:height 100})
(fb/close-artboard))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-board
{:x 0
:y 0
:width 100
:height 100})
(fb/close-board))
shape-id (::fb/last-id file)
page (fb/get-current-page file)
shape-id (::fb/last-id state)
page (fb/get-current-page state)
;; frame-id (::fb/last-id file)
data (-> (sd/make-snap-data)
(sd/add-page page))
file (-> file
(fb/delete-shape shape-id))
state (-> state
(fb/delete-shape shape-id))
new-page (fb/get-current-page file)
new-page (fb/get-current-page state)
data (sd/update-page data page new-page)
result-x (sd/query data (:id page) uuid/zero :x [0 100])
@ -212,25 +217,26 @@
(t/is (= (count result-y) 0))))
(t/testing "Create simple shape on root. Then remove it"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-shape
{:type :rect
:x 0
:y 0
:width 100
:height 100}))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-shape
{:type :rect
:x 0
:y 0
:width 100
:height 100}))
shape-id (::fb/last-id file)
page (fb/get-current-page file)
shape-id (::fb/last-id state)
page (fb/get-current-page state)
;; frame-id (::fb/last-id file)
;; frame-id (::fb/last-id state)
data (-> (sd/make-snap-data)
(sd/add-page page))
file (fb/delete-shape file shape-id)
state (fb/delete-shape state shape-id)
new-page (fb/get-current-page file)
new-page (fb/get-current-page state)
data (sd/update-page data page new-page)
result-x (sd/query data (:id page) uuid/zero :x [0 100])
@ -241,30 +247,31 @@
(t/is (= (count result-y) 0))))
(t/testing "Create shape inside frame, then remove it"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-artboard
{:x 0
:y 0
:width 100
:height 100}))
frame-id (::fb/last-id file)
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-board
{:x 0
:y 0
:width 100
:height 100}))
frame-id (::fb/last-id state)
file (fb/add-shape file {:type :rect :x 25 :y 25 :width 50 :height 50})
shape-id (::fb/last-id file)
state (fb/add-shape state {:type :rect :x 25 :y 25 :width 50 :height 50})
shape-id (::fb/last-id state)
file (fb/close-artboard file)
state (fb/close-board state)
page (fb/get-current-page file)
data (-> (sd/make-snap-data)
(sd/add-page page))
page (fb/get-current-page state)
data (-> (sd/make-snap-data)
(sd/add-page page))
file (fb/delete-shape file shape-id)
new-page (fb/get-current-page file)
state (fb/delete-shape state shape-id)
new-page (fb/get-current-page state)
data (sd/update-page data page new-page)
data (sd/update-page data page new-page)
result-zero-x (sd/query data (:id page) uuid/zero :x [0 100])
result-zero-x (sd/query data (:id page) uuid/zero :x [0 100])
result-frame-x (sd/query data (:id page) frame-id :x [0 100])]
(t/is (some? data))
@ -272,26 +279,28 @@
(t/is (= (count result-frame-x) 3))))
(t/testing "Create global guide then remove it"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-guide {:position 50 :axis :x}))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-guide {:position 50 :axis :x}))
guide-id (::fb/last-id file)
guide-id (::fb/last-id state)
file (-> (fb/add-artboard file {:x 200 :y 200 :width 100 :height 100})
(fb/close-artboard))
state (-> (fb/add-board state {:x 200 :y 200 :width 100 :height 100})
(fb/close-board))
frame-id (::fb/last-id file)
page (fb/get-current-page file)
data (-> (sd/make-snap-data) (sd/add-page page))
frame-id (::fb/last-id state)
page (fb/get-current-page state)
data (-> (sd/make-snap-data)
(sd/add-page page))
new-page (-> (fb/delete-guide file guide-id)
new-page (-> (fb/delete-guide state guide-id)
(fb/get-current-page))
data (sd/update-page data page new-page)
data (sd/update-page data page new-page)
result-zero-x (sd/query data (:id page) uuid/zero :x [0 100])
result-zero-y (sd/query data (:id page) uuid/zero :y [0 100])
result-zero-x (sd/query data (:id page) uuid/zero :x [0 100])
result-zero-y (sd/query data (:id page) uuid/zero :y [0 100])
result-frame-x (sd/query data (:id page) frame-id :x [0 100])
result-frame-y (sd/query data (:id page) frame-id :y [0 100])]
@ -305,10 +314,11 @@
(t/is (= (count result-frame-y) 0))))
(t/testing "Create frame guide then remove it"
(let [file (-> (fb/create-file {:name "Test"})
(let [file (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-artboard {:x 200 :y 200 :width 100 :height 100})
(fb/close-artboard))
(fb/add-board {:x 200 :y 200 :width 100 :height 100})
(fb/close-board))
frame-id (::fb/last-id file)
file (fb/add-guide file {:position 50 :axis :x :frame-id frame-id})
@ -336,30 +346,31 @@
(t/is (= (count result-frame-y) 0))))
(t/testing "Update frame coordinates"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-artboard
{:x 0
:y 0
:width 100
:height 100})
(fb/close-artboard))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-board
{:x 0
:y 0
:width 100
:height 100})
(fb/close-board))
frame-id (::fb/last-id file)
page (fb/get-current-page file)
data (-> (sd/make-snap-data) (sd/add-page page))
frame-id (::fb/last-id state)
page (fb/get-current-page state)
data (-> (sd/make-snap-data)
(sd/add-page page))
file (fb/update-shape file frame-id
(fn [shape]
(-> shape
(dissoc :selrect :points)
(assoc :x 200 :y 200)
(cts/setup-shape))))
state (fb/update-shape state frame-id
(fn [shape]
(-> shape
(dissoc :selrect :points)
(assoc :x 200 :y 200)
(cts/setup-shape))))
new-page (fb/get-current-page file)
data (sd/update-page data page new-page)
new-page (fb/get-current-page state)
data (sd/update-page data page new-page)
result-zero-x-1 (sd/query data (:id page) uuid/zero :x [0 100])
result-frame-x-1 (sd/query data (:id page) frame-id :x [0 100])
@ -373,28 +384,29 @@
(t/is (= (count result-frame-x-2) 3))))
(t/testing "Update shape coordinates"
(let [file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-shape
{:type :rect
:x 0
:y 0
:width 100
:height 100}))
(let [state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-shape
{:type :rect
:x 0
:y 0
:width 100
:height 100}))
shape-id (::fb/last-id file)
page (fb/get-current-page file)
shape-id (::fb/last-id state)
page (fb/get-current-page state)
data (-> (sd/make-snap-data)
(sd/add-page page))
file (fb/update-shape file shape-id
(fn [shape]
(-> shape
(dissoc :selrect :points)
(assoc :x 200 :y 200)
(cts/setup-shape))))
state (fb/update-shape state shape-id
(fn [shape]
(-> shape
(dissoc :selrect :points)
(assoc :x 200 :y 200)
(cts/setup-shape))))
new-page (fb/get-current-page file)
new-page (fb/get-current-page state)
;; FIXME: update
data (sd/update-page data page new-page)
@ -406,25 +418,26 @@
(t/is (= (count result-zero-x-2) 3))))
(t/testing "Update global guide"
(let [guide {:position 50 :axis :x}
file (-> (fb/create-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-guide guide))
(let [guide {:position 50 :axis :x}
state (-> (fb/create-state)
(fb/add-file {:name "Test"})
(fb/add-page {:name "Page 1"})
(fb/add-guide guide))
guide-id (::fb/last-id file)
guide (assoc guide :id guide-id)
guide-id (::fb/last-id state)
guide (assoc guide :id guide-id)
file (-> (fb/add-artboard file {:x 500 :y 500 :width 100 :height 100})
(fb/close-artboard))
state (-> (fb/add-board state {:x 500 :y 500 :width 100 :height 100})
(fb/close-board))
frame-id (::fb/last-id file)
page (fb/get-current-page file)
frame-id (::fb/last-id state)
page (fb/get-current-page state)
data (-> (sd/make-snap-data) (sd/add-page page))
new-page (-> (fb/update-guide file (assoc guide :position 150))
new-page (-> (fb/update-guide state (assoc guide :position 150))
(fb/get-current-page))
data (sd/update-page data page new-page)
data (sd/update-page data page new-page)
result-zero-x-1 (sd/query data (:id page) uuid/zero :x [0 100])
result-zero-y-1 (sd/query data (:id page) uuid/zero :y [0 100])

View file

@ -33,7 +33,6 @@
"dependencies": {
"@zip.js/zip.js": "patch:@zip.js/zip.js@npm%3A2.7.60#~/.yarn/patches/@zip.js-zip.js-npm-2.7.60-b6b814410b.patch",
"luxon": "^3.6.1",
"sax": "^1.4.1",
"source-map-support": "^0.5.21"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,47 +1,87 @@
import * as penpot from "../target/library/penpot.js";
import { writeFile } from 'fs/promises';
import { writeFile, readFile } from 'fs/promises';
import { createWriteStream } from 'fs';
import { Writable } from "stream";
console.log(penpot);
// console.log(penpot);
(async function() {
const file = penpot.createFile({name: "Test"});
file.addPage({name: "Foo Page"})
const boardId = file.addArtboard({name: "Foo Board"})
const rectId = file.addRect({name: "Foo Rect", width:100, height: 200})
file.addLibraryColor({color: "#fabada", opacity: 0.5})
// console.log("created board", boardId);
// console.log("created rect", rectId);
// const board = file.getShape(boardId);
// console.log("=========== BOARD =============")
// console.dir(board, {depth: 10});
// const rect = file.getShape(rectId);
// console.log("=========== RECT =============")
// console.dir(rect, {depth: 10});
const context = penpot.createBuildContext();
{
let result = await penpot.exportAsBytes(file)
context.addFile({name: "Test File 1"});
context.addPage({name: "Foo Page"})
// Add image media
const buffer = await readFile("./playground/sample.jpg");
const blob = new Blob([buffer], { type: 'image/jpeg' });
const mediaId = context.addFileMedia({
name: "avatar.jpg",
width: 512,
height: 512
}, blob);
// Add image color asset
const assetColorId = context.addLibraryColor({
name: "Avatar",
opacity: 1,
image: {
...context.getMediaAsImage(mediaId),
keepAspectRatio: true
}
});
const boardId = context.addBoard({
name: "Foo Board",
x: 0,
y: 0,
width: 500,
height: 300,
})
const fill = {
fillColorRefId: assetColorId,
fillColorRefFile: context.currentFileId,
fillImage: {
...context.getMediaAsImage(mediaId),
keepAspectRatio: true
}
};
context.addRect({
name: "Rect 1",
x: 20,
y: 20,
width:100,
height:200,
fills: [fill]
});
context.closeBoard();
context.closeFile();
}
{
let result = await penpot.exportAsBytes(context)
await writeFile("sample-sync.zip", result);
}
{
// Create a file stream to write the zip to
const output = createWriteStream('sample-stream.zip');
// Wrap Node's stream in a WHATWG WritableStream
const writable = Writable.toWeb(output);
await penpot.exportStream(file, writable);
}
// {
// // Create a file stream to write the zip to
// const output = createWriteStream('sample-stream.zip');
// // Wrap Node's stream in a WHATWG WritableStream
// const writable = Writable.toWeb(output);
// await penpot.exportStream(context, writable);
// }
})().catch((cause) => {
console.log(cause);
console.error(cause);
const innerCause = cause.cause;
if (innerCause) {
console.error("Inner cause:", innerCause);
}
process.exit(-1);
}).finally(() => {
process.exit(0);

View file

@ -25,7 +25,7 @@
:modules
{:penpot
{:exports {BuilderError lib.builder/BuilderError
createFile lib.builder/create-file
createBuildContext lib.builder/create-build-context
exportAsBytes lib.export/export-bytes
exportAsBlob lib.export/export-blob
exportStream lib.export/export-stream
@ -34,7 +34,7 @@
:js-options
{:entry-keys ["module" "browser" "main"]
:export-conditions ["module" "import", "browser" "require" "default"]
:js-provider :import
;; :js-provider :import
;; :external-index "target/library/dependencies.js"
;; :external-index-format :esm
}

View file

@ -55,201 +55,235 @@
(defn- decode-params
[params]
(if (obj/plain-object? params)
(json/->js params)
(json/->clj params)
params))
(defn- create-file-api
[file]
(let [state* (volatile! file)
api (obj/reify {:name "File"}
:id
{:get #(dm/str (:id @state*))}
(defn- get-current-page-id
[state]
(dm/str (get state ::fb/current-page-id)))
:currentFrameId
{:get #(dm/str (::fb/current-frame-id @state*))}
(defn- get-last-id
[state]
(dm/str (get state ::fb/last-id)))
:currentPageId
{:get #(dm/str (::fb/current-page-id @state*))}
(defn- create-builder-api
[state]
(obj/reify {:name "File"}
:currentFileId
{:get #(dm/str (get @state ::fb/current-file-id))}
:lastId
{:get #(dm/str (::fb/last-id @state*))}
:currentFrameId
{:get #(dm/str (get @state ::fb/current-frame-id))}
:addPage
(fn [params]
(try
(let [params (-> params
(decode-params)
(fb/decode-page))]
(vswap! state* fb/add-page params)
(dm/str (::fb/current-page-id @state*)))
(catch :default cause
(handle-exception cause))))
:currentPageId
{:get #(get-current-page-id @state)}
:closePage
(fn []
(vswap! state* fb/close-page))
:lastId
{:get #(get-last-id @state)}
:addArtboard
(fn [params]
(try
(let [params (-> params
(json/->clj)
(assoc :type :frame)
(fb/decode-shape))]
(vswap! state* fb/add-artboard params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:addFile
(fn [params]
(try
(let [params (-> params decode-params fb/decode-file)]
(-> (swap! state fb/add-file params)
(get ::fb/current-file-id)))
(catch :default cause
(handle-exception cause))))
:closeArtboard
(fn []
(vswap! state* fb/close-artboard))
:closeFile
(fn []
(swap! state fb/close-file)
nil)
:addGroup
(fn [params]
(try
(let [params (-> params
(json/->clj)
(assoc :type :group)
(fb/decode-shape))]
(vswap! state* fb/add-group params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:addPage
(fn [params]
(try
(let [params (-> (decode-params params)
(fb/decode-page))]
:closeGroup
(fn []
(vswap! state* fb/close-group))
(-> (swap! state fb/add-page params)
(get-current-page-id)))
:addBool
(fn [params]
(try
(let [params (-> params
(json/->clj)
(fb/decode-add-bool))]
(vswap! state* fb/add-bool params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
(catch :default cause
(handle-exception cause))))
:addRect
(fn [params]
(try
(let [params (-> params
(json/->clj)
(assoc :type :rect)
(fb/decode-shape))]
(vswap! state* fb/add-shape params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:closePage
(fn []
(swap! state fb/close-page)
nil)
:addCircle
(fn [params]
(try
(let [params (-> params
(json/->clj)
(assoc :type :circle)
(fb/decode-shape))]
(vswap! state* fb/add-shape params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:addBoard
(fn [params]
(try
(let [params (-> (decode-params params)
(assoc :type :frame)
(fb/decode-shape))]
(-> (swap! state fb/add-board params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addPath
(fn [params]
(try
(let [params (-> params
(json/->clj)
(assoc :type :path)
(fb/decode-shape))]
(vswap! state* fb/add-shape params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:closeBoard
(fn []
(swap! state fb/close-board)
nil)
:addText
(fn [params]
(try
(let [params (-> params
(json/->clj)
(assoc :type :text)
(fb/decode-shape))]
(vswap! state* fb/add-shape params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:addGroup
(fn [params]
(try
(let [params (-> (decode-params params)
(assoc :type :group)
(fb/decode-shape))]
(-> (swap! state fb/add-group params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addLibraryColor
(fn [params]
(try
(let [params (-> params
(json/->clj)
(fb/decode-library-color)
(d/without-nils))]
(vswap! state* fb/add-library-color params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:closeGroup
(fn []
(swap! state fb/close-group)
nil)
:addLibraryTypography
(fn [params]
(try
(let [params (-> params
(json/->clj)
(fb/decode-library-typography)
(d/without-nils))]
(vswap! state* fb/add-library-typography params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:addBool
(fn [params]
(try
(let [params (-> (decode-params params)
(fb/decode-add-bool))]
(-> (swap! state fb/add-bool params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addComponent
(fn [params]
(try
(let [params (-> params
(json/->clj)
(fb/decode-component)
(d/without-nils))]
(vswap! state* fb/add-component params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:addRect
(fn [params]
(try
(let [params (-> (decode-params params)
(assoc :type :rect)
(fb/decode-shape))]
(-> (swap! state fb/add-shape params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addComponentInstance
(fn [params]
(try
(let [params (-> params
(json/->clj)
(fb/decode-add-component-instance)
(d/without-nils))]
(vswap! state* fb/add-component-instance params)
(dm/str (::fb/last-id @state*)))
(catch :default cause
(handle-exception cause))))
:addCircle
(fn [params]
(try
(let [params (-> (decode-params params)
(assoc :type :circle)
(fb/decode-shape))]
(-> (swap! state fb/add-shape params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:getShape
(fn [shape-id]
(let [shape-id (uuid/parse shape-id)]
(some-> (fb/lookup-shape @state* shape-id)
(json/->js))))
:addPath
(fn [params]
(try
(let [params (-> (decode-params params)
(assoc :type :path)
(fb/decode-shape))]
(-> (swap! state fb/add-shape params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:toMap
(fn []
(-> @state*
(d/without-qualified)
(json/->js))))]
:addText
(fn [params]
(try
(let [params (-> (decode-params params)
(assoc :type :text)
(fb/decode-shape))]
(-> (swap! state fb/add-shape params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addLibraryColor
(fn [params]
(try
(let [params (-> (decode-params params)
(fb/decode-library-color)
(d/without-nils))]
(-> (swap! state fb/add-library-color params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addLibraryTypography
(fn [params]
(try
(let [params (-> (decode-params params)
(fb/decode-library-typography)
(d/without-nils))]
(-> (swap! state fb/add-library-typography params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addComponent
(fn [params]
(try
(let [params (-> (decode-params params)
(fb/decode-add-component))]
(-> (swap! state fb/add-component params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addComponentInstance
(fn [params]
(try
(let [params (-> (decode-params params)
(fb/decode-add-component-instance))]
(-> (swap! state fb/add-component-instance params)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:addFileMedia
(fn [params blob]
(when-not (instance? js/Blob blob)
(throw (BuilderError. "validation"
"invalid-media"
"only Blob instance are soported")))
(try
(let [blob (fb/map->BlobWrapper
{:size (.-size ^js blob)
:mtype (.-type ^js blob)
:blob blob})
params
(-> (decode-params params)
(fb/decode-add-file-media))]
(-> (swap! state fb/add-file-media params blob)
(get-last-id)))
(catch :default cause
(handle-exception cause))))
:getMediaAsImage
(fn [id]
(let [id (uuid/parse id)]
(when-let [fmedia (get-in @state [::fb/file-media id])]
(let [image {:id (get fmedia :id)
:width (get fmedia :width)
:height (get fmedia :height)
:name (get fmedia :name)
:mtype (get fmedia :mtype)}]
(json/->js (d/without-nils image))))))
:genId
(fn []
(dm/str (uuid/next)))))
(defn create-build-context
"Create an empty builder state context."
[]
(let [state (atom {})
api (create-builder-api state)]
(specify! api
cljs.core/IDeref
(-deref [_]
(d/without-qualified @state*)))))
(defn create-file
[params]
(try
(let [params (-> params json/->clj fb/decode-file)
file (fb/create-file params)]
(create-file-api file))
(catch :default cause
(handle-exception cause))))
(-deref [_] @state))))

View file

@ -8,12 +8,10 @@
"A .penpot export implementation"
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.builder :as fb]
[app.common.json :as json]
[app.common.media :as media]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.util.object :as obj]
[app.common.types.color :as types.color]
[app.common.types.component :as types.component]
[app.common.types.file :as types.file]
@ -22,8 +20,8 @@
[app.common.types.shape :as types.shape]
[app.common.types.tokens-lib :as types.tokens-lib]
[app.common.types.typography :as types.typography]
[cuerdas.core :as str]
[app.util.zip :as zip]
[cuerdas.core :as str]
[promesa.core :as p]))
(def ^:private schema:file
@ -43,9 +41,6 @@
(def ^:private encode-component
(sm/encoder types.component/schema:component sm/json-transformer))
;; (def encode-media
;; (sm/encoder ::ctf/media sm/json-transformer))
(def encode-color
(sm/encoder types.color/schema:color sm/json-transformer))
@ -68,7 +63,6 @@
"file-data-fragment"
"file-change"})
;; FIXME: move to types
(def ^:private schema:storage-object
[:map {:title "StorageObject"}
[:id ::sm/uuid]
@ -80,12 +74,6 @@
(def encode-storage-object
(sm/encoder schema:storage-object sm/json-transformer))
;; (def encode-file-thumbnail
;; (sm/encoder schema:file-thumbnail sm/json-transformer))
;; FIXME: naming
(def ^:private file-attrs
#{:id
:name
@ -96,8 +84,6 @@
(defn- generate-file-export-procs
[{:keys [id data] :as file}]
;; (prn "generate-file-export-procs")
;; (app.common.pprint/pprint file)
(cons
(let [file (cond-> (select-keys file file-attrs)
(:options data)
@ -108,11 +94,11 @@
(concat
(let [pages (get data :pages)
pages-index (get data :pages-index)]
(->> (d/enumerate pages)
(mapcat
(fn [[index page-id]]
(let [path (str "files/" id "/pages/" page-id ".json")
page (get pages-index page-id)
(let [page (get pages-index page-id)
objects (:objects page)
page (-> page
(dissoc :objects)
@ -155,45 +141,74 @@
(when-let [tokens-lib (get data :tokens-lib)]
(list [(str "files/" id "/tokens.json")
(delay (-> tokens-lib
(encode-tokens-lib tokens-lib)
encode-tokens-lib
json/encode))])))))
(defn generate-manifest-procs
[file]
(let [mdata {:id (:id file)
:name (:name file)
:features (:features file)}
(defn- generate-files-export-procs
[state]
(->> (vals (get state ::fb/files))
(mapcat generate-file-export-procs)))
(defn- generate-media-export-procs
[state]
(->> (get state ::fb/file-media)
(mapcat (fn [[file-media-id file-media]]
(let [media-id (get file-media :media-id)
media (get-in state [::fb/media media-id])
blob (get-in state [::fb/blobs media-id])]
(list
[(str "objects/" media-id (media/mtype->extension (:content-type media)))
(delay (get blob :blob))]
[(str "objects/" media-id ".json")
(delay (-> media
;; FIXME: proper encode?
(json/encode)))]
[(str "files/" (:file-id file-media) "/media/" file-media-id ".json")
(delay (-> file-media
(dissoc :file-id)
(json/encode)))]))))))
(defn- generate-manifest-procs
[state]
(let [files (->> (get state ::fb/files)
(mapv (fn [[file-id file]]
{:id file-id
:name (:name file)
:features (:features file)})))
params {:type "penpot/export-files"
:version 1
;; FIXME: set proper placeholder for replacement on build
:generated-by "penpot-lib/develop"
:files [mdata]
:files files
:relations []}]
(list
["manifest.json" (delay (json/encode params))])))
["manifest.json" (delay (json/encode params))]))
(defn- export
[file writer]
(->> (p/reduce (fn [writer [path proc]]
(let [data (deref proc)]
[state writer]
(->> (p/reduce (fn [writer [path data]]
(let [data (if (delay? data) (deref data) data)]
(js/console.log "export" path)
(->> (zip/add writer path data)
(p/fmap (constantly writer)))))
writer
(concat
(generate-manifest-procs @file)
(generate-file-export-procs @file)))
(cons (generate-manifest-procs @state)
(concat
(generate-files-export-procs @state)
(generate-media-export-procs @state))))
(p/mcat (fn [writer]
(zip/close writer)))))
(defn export-bytes
[file]
(export file (zip/writer (zip/bytes-writer))))
[state]
(export state (zip/writer (zip/bytes-writer))))
(defn export-blob
[file]
(export file (zip/writer (zip/blob-writer))))
[state]
(export state (zip/writer (zip/blob-writer))))
(defn export-stream
[file stream]
(export file (zip/writer stream)))
[state stream]
(export state (zip/writer stream)))

View file

@ -59,7 +59,6 @@ __metadata:
concurrently: "npm:^9.1.2"
luxon: "npm:^3.6.1"
nodemon: "npm:^3.1.9"
sax: "npm:^1.4.1"
shadow-cljs: "npm:3.0.5"
source-map-support: "npm:^0.5.21"
languageName: unknown
@ -73,11 +72,11 @@ __metadata:
linkType: hard
"@types/node@npm:^22.12.0":
version: 22.15.17
resolution: "@types/node@npm:22.15.17"
version: 22.15.18
resolution: "@types/node@npm:22.15.18"
dependencies:
undici-types: "npm:~6.21.0"
checksum: 10c0/fb92aa10b628683c5b965749f955bc2322485ecb0ea6c2f4cae5f2c7537a16834607e67083a9e9281faaae8d7dee9ada8d6a5c0de9a52c17d82912ef00c0fdd4
checksum: 10c0/e23178c568e2dc6b93b6aa3b8dfb45f9556e527918c947fe7406a4c92d2184c7396558912400c3b1b8d0fa952ec63819aca2b8e4d3545455fc6f1e9623e09ca6
languageName: node
linkType: hard
@ -318,14 +317,14 @@ __metadata:
linkType: hard
"debug@npm:4, debug@npm:^4, debug@npm:^4.3.4":
version: 4.4.0
resolution: "debug@npm:4.4.0"
version: 4.4.1
resolution: "debug@npm:4.4.1"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de
checksum: 10c0/d2b44bc1afd912b49bb7ebb0d50a860dc93a4dd7d946e8de94abc957bb63726b7dd5aa48c18c2386c379ec024c46692e15ed3ed97d481729f929201e671fcd55
languageName: node
linkType: hard
@ -962,13 +961,6 @@ __metadata:
languageName: node
linkType: hard
"sax@npm:^1.4.1":
version: 1.4.1
resolution: "sax@npm:1.4.1"
checksum: 10c0/6bf86318a254c5d898ede6bd3ded15daf68ae08a5495a2739564eb265cd13bcc64a07ab466fb204f67ce472bb534eb8612dac587435515169593f4fffa11de7c
languageName: node
linkType: hard
"semver@npm:^7.3.5, semver@npm:^7.5.3":
version: 7.7.2
resolution: "semver@npm:7.7.2"

View file

@ -11,13 +11,14 @@
},
"type": "module",
"scripts": {
"fmt:clj:check": "cljfmt check --parallel=true common/src/ common/test/ frontend/src/ frontend/test/ backend/src/ backend/test/ exporter/src/",
"fmt:clj": "cljfmt fix --parallel=true common/src/ common/test/ frontend/src/ frontend/test/ backend/src/ backend/test/ exporter/src/",
"fmt:clj:check": "cljfmt check --parallel=true common/src/ common/test/ frontend/src/ frontend/test/ backend/src/ backend/test/ exporter/src/ library/src",
"fmt:clj": "cljfmt fix --parallel=true common/src/ common/test/ frontend/src/ frontend/test/ backend/src/ backend/test/ exporter/src/ library/src",
"lint:clj:common": "clj-kondo --parallel=true --lint common/src",
"lint:clj:frontend": "clj-kondo --parallel=true --lint frontend/src",
"lint:clj:backend": "clj-kondo --parallel=true --lint backend/src",
"lint:clj:exporter": "clj-kondo --parallel=true --lint exporter/src",
"lint:clj": "yarn run lint:clj:common && yarn run lint:clj:frontend && yarn run lint:clj:backend && yarn run lint:clj:exporter"
"lint:clj:library": "clj-kondo --parallel=true --lint library/src",
"lint:clj": "yarn run lint:clj:common && yarn run lint:clj:frontend && yarn run lint:clj:backend && yarn run lint:clj:exporter && yarn run lint:clj:library"
},
"devDependencies": {
"@playwright/test": "^1.43.1",