Skip to content

Commit a1cfb3e

Browse files
authored
Merge pull request #147 from geostyler/26-handle-base64-image-as-inline-content
feat(#26): write layer url as icon image
2 parents 3f98fd7 + 869aeca commit a1cfb3e

File tree

12 files changed

+1604
-126
lines changed

12 files changed

+1604
-126
lines changed

package-lock.json

Lines changed: 811 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"lint:typecheck:test": "npm run lint && npm run typecheck && npm run test"
3838
},
3939
"dependencies": {
40-
"geostyler-style": "^10.4.0"
40+
"geostyler-style": "^10.4.0",
41+
"jimp": "^1.6.0"
4142
},
4243
"devDependencies": {
4344
"@babel/cli": "^7.25.7",
@@ -46,6 +47,7 @@
4647
"@babel/preset-typescript": "^7.25.7",
4748
"@terrestris/eslint-config-typescript": "^5.0.0",
4849
"@types/node": "^22.0.0",
50+
"@types/pngjs": "^6.0.5",
4951
"@typescript-eslint/eslint-plugin": "^7.12.0",
5052
"@typescript-eslint/eslint-plugin-tslint": "^7.0.2",
5153
"@typescript-eslint/parser": "^7.12.0",

src/base64Utils.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Jimp } from "jimp";
2+
import { WARNINGS } from "./toGeostylerUtils.ts";
3+
4+
/** Split base64 string */
5+
interface Base64ImageInfo {
6+
data: string;
7+
extension: string;
8+
}
9+
10+
type SupportedMimeTypes =
11+
| "image/bmp"
12+
| "image/tiff"
13+
| "image/x-ms-bmp"
14+
| "image/gif"
15+
| "image/jpeg"
16+
| "image/png";
17+
18+
/**
19+
* Get the data and extension from a base64 string.
20+
* @returns The data and extension or undefined if the string is not a base64 string.
21+
*/
22+
const getBase64ImageInfo = (
23+
base64String: string,
24+
): Base64ImageInfo | undefined => {
25+
const tokens = base64String.split(";");
26+
if (tokens.length !== 2) {
27+
return undefined;
28+
}
29+
const ext = tokens[0].split("/").pop();
30+
if (!ext) {
31+
return undefined;
32+
}
33+
return {
34+
data: tokens[1].substring("base64,".length),
35+
extension: ext,
36+
};
37+
};
38+
39+
/**
40+
* Resize a base64 image to the given size using Jimp.
41+
* This is needed because the end reader could be not able to resize a base64 image.
42+
* @returns The resized base64 image or the original base64 image if resizing failed.
43+
*/
44+
export const resizeBase64Image = async (
45+
base64String: string | undefined,
46+
size: number,
47+
): Promise<string | undefined> => {
48+
if (!base64String) {
49+
return undefined;
50+
}
51+
const imageInfo = getBase64ImageInfo(base64String);
52+
if (!imageInfo) {
53+
return base64String;
54+
}
55+
const mimeType = `image/${imageInfo.extension}` as SupportedMimeTypes;
56+
const buffer = Buffer.from(imageInfo.data, "base64");
57+
let resizedBase64Image: string | undefined;
58+
try {
59+
const image = await Jimp.read(buffer);
60+
const resizedImage = image.resize({
61+
w: Math.floor(size),
62+
h: Math.floor(size),
63+
});
64+
resizedBase64Image = await resizedImage.getBase64(mimeType);
65+
} catch (e) {
66+
WARNINGS.push(`Could not resize image: ${e}`);
67+
}
68+
if (resizedBase64Image) {
69+
return resizedBase64Image;
70+
}
71+
return base64String;
72+
};

src/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
export const ESRI_SYMBOLS_FONT: string = "ESRI Default Marker";
22
export const POLYGON_FILL_RESIZE_FACTOR: number = 2 / 3;
33
export const OFFSET_FACTOR: number = 4 / 3;
4-
export const ESRI_SPECIAL_FONT: string[] = ["ttf://ESRI SDS 2.00", "ttf://ESRI SDS 1.95"];
4+
export const ESRI_SPECIAL_FONT: string[] = [
5+
"ttf://ESRI SDS 2.00",
6+
"ttf://ESRI SDS 1.95",
7+
];
58
export const ESRI_SPECIAL_FONT_RESIZE_FACTOR: number = 1.8;
69

710
export enum MarkerPlacementPosition {

src/esri/types/symbols/CIMSymbol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type SymbolLayer = CIMObject & {
3434
size: number;
3535
symbol: CIMSymbol;
3636
respectFrame: boolean;
37+
url?: string;
3738
};
3839

3940
export type CIMSymbol = CIMObject & {

src/expressions.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,23 @@ export const convertExpression = (
6363
}
6464

6565
// Handle if-then-else expressions.
66-
const regex = /\[\s*if\s*\(([^)]+)\)\s*{([^}]*)}\s*\]/si;
66+
const regex = /\[\s*if\s*\(([^)]+)\)\s*{([^}]*)}\s*\]/is;
6767
const match = expression.match(regex);
6868
if (match) {
6969
const condition = match[1];
7070
const thenPart = match[2];
71-
const elsePart = match.length === 4 ? convertArcadeExpression(match[3]) : "";
71+
const elsePart =
72+
match.length === 4 ? convertArcadeExpression(match[3]) : "";
7273

73-
const booleanFunction = processGeostylerBooleanFunction(condition, toLowerCase);
74+
const booleanFunction = processGeostylerBooleanFunction(
75+
condition,
76+
toLowerCase,
77+
);
7478
if (booleanFunction) {
7579
const processedThen = convertExpression(thenPart, engine, toLowerCase);
7680
return {
7781
name: "if_then_else",
78-
args: [booleanFunction, processedThen, elsePart]
82+
args: [booleanFunction, processedThen, elsePart],
7983
} as unknown as GeoStylerStringFunction;
8084
}
8185
return processPropertyName(expression);
@@ -183,61 +187,63 @@ export const processRoundExpression = (
183187
language: string,
184188
): GeoStylerStringFunction | string => {
185189
// Match expressions like "Round($feature.CONTOUR, 0)" and processes the field and decimal places
186-
const match = expression.match(/(?:{{\s*)?round\(\s*(\w+)\s*,\s*(\d+)\s*\)\s*(?:}})?/);
190+
const match = expression.match(
191+
/(?:{{\s*)?round\(\s*(\w+)\s*,\s*(\d+)\s*\)\s*(?:}})?/,
192+
);
187193
if (match) {
188194
const field = match[1];
189195
const decimalPlaces = Number(match[2]);
190196
const fProperty: Fproperty = fieldToFProperty(field, toLowerCase);
191197
let decimalFormat: string;
192198
if (decimalPlaces === 0) {
193-
decimalFormat = '#';
199+
decimalFormat = "#";
194200
} else {
195-
decimalFormat = '#.' + '#'.repeat(decimalPlaces);
201+
decimalFormat = "#." + "#".repeat(decimalPlaces);
196202
}
197203
return {
198204
args: [decimalFormat, fProperty, language],
199-
name: "numberFormat"
205+
name: "numberFormat",
200206
};
201207
}
202208
return expression;
203209
};
204210

205211
export const processGeostylerBooleanFunction = (
206212
expression: string,
207-
toLowerCase: boolean
213+
toLowerCase: boolean,
208214
): GeoStylerFunction | null => {
209215
const whereClause = convertWhereClause(expression, toLowerCase);
210216
if (Array.isArray(whereClause) && whereClause.length === 3) {
211217
const fProperty: Fproperty = fieldToFProperty(
212218
String(whereClause[1]),
213-
toLowerCase
219+
toLowerCase,
214220
);
215221
const value = Number(whereClause[2]);
216222
if (whereClause[0] === ">") {
217223
return {
218224
name: "greaterThan",
219-
args: [fProperty, value]
225+
args: [fProperty, value],
220226
};
221-
}
227+
}
222228

223229
if (whereClause[0] === "==") {
224230
return {
225231
name: "equalTo",
226-
args: [fProperty, value]
232+
args: [fProperty, value],
227233
};
228234
}
229-
235+
230236
if (whereClause[0] === "<") {
231237
return {
232238
name: "lessThan",
233-
args: [fProperty, value]
239+
args: [fProperty, value],
234240
};
235241
}
236-
242+
237243
if (whereClause[0] === "!=") {
238244
return {
239245
name: "notEqualTo",
240-
args: [fProperty, value]
246+
args: [fProperty, value],
241247
};
242248
}
243249
}

src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "geostyler-style";
88
import { convert } from "./toGeostyler.ts";
99
import { CIMLayerDocument } from "./esri/types/CIMLayerDocument.ts";
10+
import { WARNINGS } from "./toGeostylerUtils.ts";
1011

1112
export type ConstructorParams = {};
1213

@@ -30,14 +31,14 @@ export class LyrxParser implements StyleParser<CIMLayerDocument> {
3031
this.options = options || {};
3132
}
3233

33-
readStyle(inputStyle: CIMLayerDocument): Promise<ReadStyleResult> {
34-
const geostylerStyle = convert(inputStyle);
34+
async readStyle(inputStyle: CIMLayerDocument): Promise<ReadStyleResult> {
35+
const geostylerStyle = await convert(inputStyle);
3536
return Promise.resolve({
3637
output: {
3738
name: geostylerStyle[0].name,
3839
rules: geostylerStyle[0].rules,
3940
},
40-
warnings: [],
41+
warnings: WARNINGS,
4142
errors: [],
4243
});
4344
}

src/processSymbolLayer.ts

Lines changed: 21 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,13 @@ import {
3939
SymbolLayer,
4040
} from "./esri/types/symbols";
4141
import { fieldToFProperty } from "./expressions.ts";
42-
// import { writeFileSync, existsSync, mkdirSync } from 'fs';
43-
// import uuid from 'uuid';
44-
// import { tmpdir } from 'os';
45-
// import path from 'path';
42+
import { resizeBase64Image } from "./base64Utils.ts";
4643

47-
export const processSymbolLayer = (
44+
export const processSymbolLayer = async (
4845
layer: SymbolLayer,
4946
symbol: CIMSymbol,
5047
options: Options,
51-
): Symbolizer[] | undefined => {
48+
): Promise<Symbolizer[] | undefined> => {
5249
let layerType: string = layer.type;
5350

5451
switch (layerType) {
@@ -63,9 +60,8 @@ export const processSymbolLayer = (
6360
case "CIMHatchFill":
6461
return processSymbolHatchFill(layer);
6562
case "CIMPictureFill":
66-
return processSymbolPicture(layer, symbol, options);
6763
case "CIMPictureMarker":
68-
return processSymbolMarker(layer);
64+
return await processSymbolPicture(layer, symbol, options);
6965
default:
7066
return;
7167
}
@@ -333,14 +329,16 @@ const processOrientedMarkerAtEndOfLine = (
333329
const processMarkerPlacementInsidePolygon = (
334330
symbolizer: MarkSymbolizer,
335331
markerPlacement: CIMMarkerPlacement,
336-
respectFrame: boolean = true
332+
respectFrame: boolean = true,
337333
): [
338334
Expression<number>,
339335
Expression<number>,
340336
Expression<number>,
341337
Expression<number>,
342338
] => {
343-
const isSpecialFont = ESRI_SPECIAL_FONT.some(font => symbolizer?.wellKnownName?.startsWith(font));
339+
const isSpecialFont = ESRI_SPECIAL_FONT.some((font) =>
340+
symbolizer?.wellKnownName?.startsWith(font),
341+
);
344342
// In case of markers in a polygon fill, it seems ArcGIS does some undocumented resizing of the marker.
345343
// We use an empirical factor to account for this, which works in most cases. For special Fonts we need to
346344
// use another empirical factor when respectFrame is set to false.
@@ -528,11 +526,11 @@ const processSymbolCharacterMarker = (
528526
return [symbolCharacterMaker];
529527
};
530528

531-
const processSymbolVectorMarker = (
529+
const processSymbolVectorMarker = async (
532530
layer: SymbolLayer,
533531
cimSymbol: CIMSymbol,
534532
options: Options,
535-
): Symbolizer[] => {
533+
): Promise<Symbolizer[]> => {
536534
if (layer.size) {
537535
layer.size = ptToPxProp(layer, "size", 3);
538536
}
@@ -553,7 +551,8 @@ const processSymbolVectorMarker = (
553551
// TODO: support multiple marker graphics
554552
const markerGraphic = markerGraphics[0];
555553
if (markerGraphic.symbol && markerGraphic.symbol.symbolLayers) {
556-
symbol = processSymbolReference(markerGraphic, {})[0] as MarkSymbolizer;
554+
const symbolReferences = await processSymbolReference(markerGraphic, {});
555+
symbol = symbolReferences[0] as MarkSymbolizer;
557556
const subLayers = markerGraphic.symbol.symbolLayers.filter(
558557
(sublayer: SymbolLayer) => sublayer.enable,
559558
);
@@ -695,67 +694,31 @@ const processSymbolHatchFill = (layer: SymbolLayer): Symbolizer[] => {
695694
return [fillSymbolizer];
696695
};
697696

698-
const processSymbolPicture = (
697+
const processSymbolPicture = async (
699698
layer: SymbolLayer,
700699
cimSymbol: CIMSymbol,
701700
options: Options,
702-
): Symbolizer[] => {
703-
// let url = layer.url;
704-
// if (!existsSync(url)) {
705-
// let tokens = url.split(';');
706-
// if (tokens.length === 2) {
707-
// let ext = tokens[0].split('/').pop();
708-
// let data = tokens[1].substring('base64,'.length);
709-
// let tempPath = path.join(
710-
// tmpdir(),
711-
// 'bridgestyle',
712-
// uuid.v4().replace('-', ''),
713-
// );
714-
// let iconName = `${uuid.v4()}.${ext}`;
715-
// let iconFile = path.join(tempPath, iconName);
716-
// mkdirSync(tempPath, { recursive: true });
717-
// writeFileSync(iconFile, Buffer.from(data, 'base64'));
718-
// usedIcons.push(iconFile);
719-
// url = iconFile;
720-
// }
721-
// }
722-
723-
let size = ptToPxProp(layer, "height", ptToPxProp(layer, "size", 0));
724-
const picureFillSymbolizer: Symbolizer = {
701+
): Promise<Symbolizer[]> => {
702+
const size = ptToPxProp(layer, "height", ptToPxProp(layer, "size", 0));
703+
const resizedImage = (await resizeBase64Image(layer.url, size)) ?? "";
704+
const pictureFillSymbolizer: Symbolizer = {
725705
opacity: 1.0,
726-
rotate: 0.0,
706+
rotate: 0,
727707
kind: "Icon",
728-
color: undefined,
729-
// image: url,
730-
image: "http://FIXME",
708+
image: resizedImage,
731709
size: size,
732710
};
733711

734712
const symbolizerWithSubSymbolizer = processSymbolLayerWithSubSymbol(
735713
cimSymbol,
736714
layer,
737-
picureFillSymbolizer,
715+
pictureFillSymbolizer,
738716
options,
739717
);
740718
if (symbolizerWithSubSymbolizer.length) {
741719
return symbolizerWithSubSymbolizer;
742720
}
743-
return [picureFillSymbolizer];
744-
};
745-
746-
const processSymbolMarker = (layer: SymbolLayer): Symbolizer[] => {
747-
let size = ptToPxProp(layer, "height", ptToPxProp(layer, "size", 0));
748-
return [
749-
{
750-
opacity: 1.0,
751-
rotate: 0.0,
752-
kind: "Icon",
753-
color: undefined,
754-
// image: url,
755-
image: "http://FIXME",
756-
size: size,
757-
} as Symbolizer,
758-
];
721+
return [pictureFillSymbolizer];
759722
};
760723

761724
const extractEffect = (layer: SymbolLayer): Effect => {

0 commit comments

Comments
 (0)