import fs from "fs"; import path from "path"; import React from "react"; import { computed, observable, action, makeObservable, runInAction, autorun //,autorun } from "mobx"; import { observer } from "mobx-react"; import { dialog, getCurrentWindow } from "@electron/remote"; import * as notification from "eez-studio-ui/notification"; import { Button } from "eez-studio-ui/button"; import { ClassInfo, IEezObject, EezObject, registerClass, PropertyType, MessageType, PropertyProps, IMessage } from "project-editor/core/object"; import { validators } from "eez-studio-shared/validation"; import { createObject, getProjectStore, getUniquePropertyValue, Message, ProjectStore, propertyNotSetMessage } from "project-editor/store"; import { getThemedColor } from "project-editor/features/style/theme"; import { showGenericDialog } from "project-editor/core/util"; import { MultipleAbsoluteFileInput } from "project-editor/ui-components/FileInput"; import { getProject, findStyle } from "project-editor/project/project"; import { ProjectEditor } from "project-editor/project-editor-interface"; import { generalGroup } from "project-editor/ui-components/PropertyGrid/groups"; import { BitmapColorFormat, isDashboardProject, isLVGLProject } from "project-editor/project/project-type-traits"; import { IFieldProperties } from "eez-studio-types"; import type { ProjectEditorFeature } from "project-editor/store/features"; import { getLvglBitmapColorFormats } from "project-editor/lvgl/lvgl-versions"; import { CF_TRUE_COLOR_ALPHA } from "project-editor/lvgl/lvgl-constants"; //////////////////////////////////////////////////////////////////////////////// const ExportBitmapFilePropertyGridUI = observer( class ExportBitmapFilePropertyGridUI extends React.Component { export = async () => { const bitmap = this.props.objects[0] as Bitmap; // for example: data:image/png;base64, const i = bitmap.image.indexOf("/"); const j = bitmap.image.indexOf(";"); const k = bitmap.image.indexOf(","); const ext = bitmap.image.substring(i + 1, j); const result = await dialog.showSaveDialog(getCurrentWindow(), { filters: [{ name: "All Files", extensions: ["*"] }], defaultPath: bitmap.name + "." + ext }); let filePath = result.filePath; if (filePath) { const bin = Buffer.from( bitmap.image.substring(k + 1), "base64" ); try { await fs.promises.writeFile(filePath, bin); notification.info(`Bitmap file exported.`); } catch (error) { notification.error(error.toString()); } } }; render() { if (this.props.objects.length > 1) { return null; } return (
); } } ); //////////////////////////////////////////////////////////////////////////////// export class Bitmap extends EezObject { id: number | undefined; name: string; description?: string; image: string; bpp: number; alwaysBuild: boolean; style?: string; constructor() { super(); makeObservable(this, { backgroundColor: computed, _imageElement: observable, imageElement: computed({ keepAlive: true }), bitmapData: computed({ keepAlive: true }) }); } override makeEditable() { super.makeEditable(); makeObservable(this, { id: observable, name: observable, description: observable, image: observable, bpp: observable, alwaysBuild: observable, style: observable }); } static classInfo: ClassInfo = { properties: [ { name: "id", type: PropertyType.Number, isOptional: true, unique: true, propertyGridGroup: generalGroup, disabled: (bitmap: Bitmap) => isLVGLProject(bitmap) || isDashboardProject(bitmap) }, { name: "name", type: PropertyType.String, unique: true }, { name: "description", type: PropertyType.MultilineText }, { name: "image", type: PropertyType.Image, skipSearch: true, disableBitmapPreview: true, embeddedImage: true }, { name: "bpp", displayName: (bitmap: Bitmap) => isLVGLProject(bitmap) ? "Color format" : "Bits per pixel", type: PropertyType.Enum, enumItems: (bitmap: Bitmap) => isLVGLProject(bitmap) ? getLvglBitmapColorFormats(bitmap) : [{ id: 16 }, { id: 32 }], defaultValue: 16, disabled: isDashboardProject }, { name: "style", type: PropertyType.ObjectReference, referencedObjectCollectionPath: "allStyles", disabled: (bitmap: Bitmap) => isLVGLProject(bitmap) || isDashboardProject(bitmap) || bitmap.bpp == 32 }, { name: "alwaysBuild", displayName: "Always add to the generated code", type: PropertyType.Boolean, disabled: object => isLVGLProject(object) || isDashboardProject(object) }, { name: "customUI", type: PropertyType.Any, computed: true, propertyGridRowComponent: ExportBitmapFilePropertyGridUI, skipSearch: true, hideInPropertyGrid: (bitmap: Bitmap) => bitmap.image && typeof bitmap.image == "string" && bitmap.image.startsWith("data:image/") ? false : true } ], propertiesPanelLabel: (bitmap: Bitmap) => { return `Bitmap: ${bitmap.name}`; }, check: (bitmap: Bitmap, messages: IMessage[]) => { const projectStore = getProjectStore(bitmap); if (!bitmap.image) { messages.push(propertyNotSetMessage(bitmap, "image")); } ProjectEditor.checkAssetId( projectStore, "bitmaps", bitmap, messages ); }, newItem: async (parent: IEezObject) => { const projectStore = getProjectStore(parent); const result = await showGenericDialog(projectStore, { dialogDefinition: { title: "New Bitmap", fields: [ { name: "name", type: "string", validators: [ validators.required, validators.invalidCharacters("."), validators.unique({}, parent) ], visible: (values: any) => { return !( values.imageFilePaths && values.imageFilePaths.length > 1 ); } }, { name: "imageFilePaths", displayName: "Image", type: MultipleAbsoluteFileInput, validators: [validators.required], options: { filters: [ { name: "Image files", extensions: ["png", "jpg", "jpeg"] }, { name: "All Files", extensions: ["*"] } ] } }, ...(projectStore.projectTypeTraits.isLVGL ? [ { name: "bpp", displayName: "Color format", type: "enum", enumItems: getLvglBitmapColorFormats(parent) } as IFieldProperties ] : projectStore.projectTypeTraits.isDashboard ? [] : [ { name: "bpp", displayName: "Bits per pixel", type: "enum", enumItems: [16, 32] } as IFieldProperties ]) ] }, values: { bpp: projectStore.projectTypeTraits.isLVGL ? CF_TRUE_COLOR_ALPHA : 32 } }); const bpp: number = result.values.bpp; if (result.values.imageFilePaths.length == 1) { const name: string = result.values.name; return createBitmap( projectStore, result.values.imageFilePaths[0], undefined, name, bpp ); } else { projectStore.undoManager.setCombineCommands(true); for (let i = 0; i < result.values.imageFilePaths.length; i++) { const filePath = result.values.imageFilePaths[i]; let name = getUniquePropertyValue( projectStore.project.bitmaps, "name", path.parse(filePath).name ) as string; const bitmap = await createBitmap( projectStore, filePath, undefined, name, bpp ); if (bitmap) { projectStore.addObject(parent, bitmap); } } projectStore.undoManager.setCombineCommands(false); return undefined; } }, icon: "material:image", afterLoadHook: (bitmap: Bitmap, project) => { bitmap.migrateLvglBitmap(project._store); }, updateObjectValueHook: (bitmap: Bitmap, values: any) => { if (values.image != undefined && bitmap.image != values.image) { const project = ProjectEditor.getProject(bitmap); if (project.projectTypeTraits.isLVGL) { project._store.updateObject(bitmap, {}); } } }, // MIGRATION TO LOW RES beforeLoadHook: (bitmap: Bitmap, jsWidget: Partial) => { if ((window as any).__eezProjectMigration) { const dispose = autorun(() => { const image = bitmap.imageElement; if (image) { dispose(); setTimeout(() => { let canvas = document.createElement("canvas"); canvas.width = Math.floor( (image.width * __eezProjectMigration.displayTargetWidth) / __eezProjectMigration.displaySourceWidth ); canvas.height = Math.floor( (image.height * __eezProjectMigration.displayTargetHeight) / __eezProjectMigration.displaySourceHeight ); let ctx = canvas.getContext("2d"); if (ctx == null) { return; } if (bitmap.backgroundColor !== "transparent") { ctx.fillStyle = bitmap.backgroundColor; ctx.fillRect(0, 0, canvas.width, canvas.height); } else { ctx.clearRect( 0, 0, canvas.width, canvas.height ); } ctx.drawImage( image, 0, 0, canvas.width, canvas.height ); runInAction(() => { bitmap.image = canvas.toDataURL(); }); }, 1000); } }); } } }; private _imageElement: HTMLImageElement | null | undefined = undefined; private _imageElementImage: string; get backgroundColor() { if ( !isLVGLProject(this) && !isDashboardProject(this) && this.bpp !== 32 ) { const style = findStyle( ProjectEditor.getProject(this), this.style || "default" ); if (style && style.backgroundColorProperty) { return getThemedColor( getProjectStore(this), style.backgroundColorProperty ).colorValue; } } return "transparent"; } get imageSrc() { if (!this.image) { return ""; } if (this.image.startsWith("data:image/")) { return this.image; } return ProjectEditor.getProjectStore(this).getAbsoluteFilePath( this.image ); } get imageElement() { if (!this.image) { return null; } if ( this._imageElement === undefined || this.image !== this._imageElementImage ) { let imageElement = new Image(); imageElement.src = this.imageSrc; imageElement.onload = action(() => { this._imageElement = imageElement; this._imageElementImage = this.image; }); imageElement.onerror = action(() => { this._imageElement = null; this._imageElementImage = this.image; }); return undefined; } return this._imageElement; } getBitmapData(bpp: number) { const image = this.imageElement; if (!(image instanceof HTMLImageElement)) { return image; } let canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height; let ctx = canvas.getContext("2d"); if (ctx == null) { return undefined; } if (this.backgroundColor !== "transparent") { ctx.fillStyle = this.backgroundColor; ctx.fillRect(0, 0, image.width, image.height); } else { ctx.clearRect(0, 0, image.width, image.height); } ctx.drawImage(image, 0, 0); let imageData = ctx.getImageData(0, 0, image.width, image.height).data; const isLVGL = isLVGLProject(this); const bytesPerPixel = bpp == 32 ? 4 : bpp == 24 ? 3 : isLVGL ? 3 : 2; // for LVGL 16 bit is actually RGB565A8 (24 bit) let pixels = new Uint8Array(bytesPerPixel * image.width * image.height); const rgb = getProject(this).projectTypeTraits.bitmapColorFormat == BitmapColorFormat.RGB; for (let i = 0; i < 4 * image.width * image.height; i += 4) { let r = imageData[i]; let g = imageData[i + 1]; let b = imageData[i + 2]; let a = imageData[i + 3]; if (bpp === 32) { if (rgb) { pixels[i] = r; pixels[i + 1] = g; pixels[i + 2] = b; pixels[i + 3] = a; } else { pixels[i] = b; pixels[i + 1] = g; pixels[i + 2] = r; pixels[i + 3] = a; } } else if (bpp == 24) { if (rgb) { pixels[3 * (i / 4) + 0] = r; pixels[3 * (i / 4) + 1] = g; pixels[3 * (i / 4) + 2] = b; } else { pixels[3 * (i / 4) + 0] = b; pixels[3 * (i / 4) + 1] = g; pixels[3 * (i / 4) + 2] = r; } } else { // rrrrrggggggbbbbb pixels[i / 2] = ((g & 28) << 3) | (b >> 3); pixels[i / 2 + 1] = (r & 248) | (g >> 5); if (isLVGL) { pixels[2 * image.width * image.height + i / 4] = a; } } } return { width: image.width, height: image.height, bpp, pixels }; } get bitmapData() { return this.getBitmapData(this.bpp); } async migrateLvglBitmap(projectStore: ProjectStore) { if (this.image.startsWith("data:image/")) { return; } // migrate from assets folder to the embedded asset const absoluteFilePath = projectStore.getAbsoluteFilePath(this.image); const imageData = await fs.promises.readFile( absoluteFilePath, "base64" ); const ext = path.extname(absoluteFilePath).toLowerCase(); let fileType: string; if (ext == ".jpg" || ext == ".jpeg") { fileType = "image/jpg"; } else { fileType = "image/png"; } runInAction(() => { this.image = `data:${fileType};base64,` + imageData; projectStore.setModified(Symbol()); }); } } registerClass("Bitmap", Bitmap); export async function createBitmap( projectStore: ProjectStore, filePath: string, fileType?: string, name?: string, bpp?: number ) { if (fileType == undefined) { const ext = path.extname(filePath).toLowerCase(); if (ext == ".jpg" || ext == ".jpeg") { fileType = "image/jpg"; } else { fileType = "image/png"; } } if (bpp == undefined) { bpp = projectStore.projectTypeTraits.isLVGL ? CF_TRUE_COLOR_ALPHA : 32; } if (!name) { name = getUniquePropertyValue( projectStore.project.bitmaps, "name", path.parse(filePath).name ) as string; } try { const result = fs.readFileSync(filePath, "base64"); const bitmapProperties: Partial = { name, image: `data:${fileType};base64,` + result, bpp, alwaysBuild: false }; const bitmap = createObject( projectStore, bitmapProperties, Bitmap ); return bitmap; } catch (err) { notification.error(err); return undefined; } } export async function createBitmapFromFile( projectStore: ProjectStore, file: File ) { let fileType = file.type; if (file.type == undefined) { const ext = path.extname(file.name).toLowerCase(); if (ext == ".jpg" || ext == ".jpeg") { fileType = "image/jpg"; } else { fileType = "image/png"; } } let bpp = projectStore.projectTypeTraits.isLVGL ? CF_TRUE_COLOR_ALPHA : 32; let name = getUniquePropertyValue( projectStore.project.bitmaps, "name", path.parse(file.name).name ) as string; try { const arrayBuffer = await file.arrayBuffer(); const base64 = btoa( String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)) ); const bitmapProperties: Partial = { name, image: `data:${fileType};base64,` + base64, bpp, alwaysBuild: false }; const bitmap = createObject( projectStore, bitmapProperties, Bitmap ); return bitmap; } catch (err) { notification.error(err); return undefined; } } //////////////////////////////////////////////////////////////////////////////// export interface BitmapData { width: number; height: number; bpp: number; style?: string; pixels: Uint8Array; } export function getBitmapData( bitmap: Bitmap, bppOverride?: number ): BitmapData { const bitmapData = bppOverride != undefined ? bitmap.getBitmapData(bppOverride) : bitmap.bitmapData; if (bitmapData) { return bitmapData; } return { width: 1, height: 1, bpp: 32, pixels: new Uint8Array([0, 0, 0, 0]) }; } // this function makes sure that bitmap is eventually loaded export async function getBitmapDataAsync( bitmap: Bitmap, bppOverride?: number ): Promise { while (true) { const bitmapData = bppOverride != undefined ? bitmap.getBitmapData(bppOverride) : bitmap.bitmapData; if (bitmapData) { return bitmapData; } if (bitmapData === null) { return { width: 1, height: 1, bpp: 32, pixels: new Uint8Array([0, 0, 0, 0]) }; } // still loading, wait for 10 ms await new Promise(resolve => setTimeout(resolve, 10)); } } export async function preloadAllBitmaps(projectStore: ProjectStore) { const bitmaps: Bitmap[] = []; for (const project of projectStore.project._store.openProjectsManager .projects) { bitmaps.push(...project.bitmaps); } return Promise.all(bitmaps.map(bitmap => getBitmapDataAsync(bitmap))); } //////////////////////////////////////////////////////////////////////////////// const feature: ProjectEditorFeature = { name: "eezstudio-project-feature-bitmap", version: "0.1.0", description: "Bitmaps support for your project", author: "EEZ", authorLogo: "../eez-studio-ui/_images/eez_logo.png", displayName: "Bitmaps", mandatory: false, key: "bitmaps", type: PropertyType.Array, typeClass: Bitmap, icon: "material:image", create: () => [], check: (projectStore, object: EezObject[], messages: IMessage[]) => { if (object.length > 65535) { messages.push( new Message( MessageType.ERROR, "Max. 65535 bitmaps are supported", object ) ); } if ( !projectStore.projectTypeTraits.isDashboard && !projectStore.projectTypeTraits.isLVGL && !findStyle(projectStore.project, "default") ) { messages.push( new Message( MessageType.ERROR, "'Default' style is missing.", object ) ); } } }; export default feature;