From c677d85cd7c9de137cc588020e1877c76ae49a89 Mon Sep 17 00:00:00 2001 From: Wentao Kuang Date: Thu, 4 Jun 2026 11:33:01 +1200 Subject: [PATCH 1/3] New command to process topo50 reliefshade --- packages/cli-raster/src/cogify/cli.ts | 2 + .../src/cogify/cli/cli.topo.reliefshade.ts | 256 ++++++++++++++++++ .../cli-raster/src/cogify/cli/cli.topo.ts | 4 +- .../src/cogify/gdal/gdal.command.ts | 37 ++- 4 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 packages/cli-raster/src/cogify/cli/cli.topo.reliefshade.ts diff --git a/packages/cli-raster/src/cogify/cli.ts b/packages/cli-raster/src/cogify/cli.ts index c967dfd624..48155538f1 100644 --- a/packages/cli-raster/src/cogify/cli.ts +++ b/packages/cli-raster/src/cogify/cli.ts @@ -5,6 +5,7 @@ import { BasemapsCogifyCreateCommand } from './cli/cli.cog.js'; import { BasemapsCogifyCoverCommand } from './cli/cli.cover.js'; import { TopoStacCreationCommand } from './cli/cli.topo.js'; import { BasemapsCogifyValidateCommand } from './cli/cli.validate.js'; +import { TopoReliefShadeCreationCommand } from './cli/cli.topo.reliefshade.js'; export const CogifyCli = subcommands({ name: 'cogify', @@ -13,6 +14,7 @@ export const CogifyCli = subcommands({ create: BasemapsCogifyCreateCommand, validate: BasemapsCogifyValidateCommand, topo: TopoStacCreationCommand, + 'topo-reliefshade': TopoReliefShadeCreationCommand, charts: ChartsCreationCommand, }, }); diff --git a/packages/cli-raster/src/cogify/cli/cli.topo.reliefshade.ts b/packages/cli-raster/src/cogify/cli/cli.topo.reliefshade.ts new file mode 100644 index 0000000000..7041d4ea6d --- /dev/null +++ b/packages/cli-raster/src/cogify/cli/cli.topo.reliefshade.ts @@ -0,0 +1,256 @@ +import { fsa, LogType, Tiff, urlToString } from '@basemaps/shared'; +import { getLogger, logArguments, UrlFolder } from '@basemaps/shared'; +import { CliDate, CliId, CliInfo } from '@basemaps/shared/build/cli/info.js'; +import { command, option, optional, string } from 'cmd-ts'; +import { gdalBuildTopoReliefShadeCommands } from '../gdal/gdal.command.js'; +import { GdalRunner } from '../gdal/gdal.runner.js'; +import { basename } from 'path/win32'; +import { mkdir } from 'fs/promises'; +import { SpatialExtents, StacCollection, StacItem } from 'stac-ts'; +import { Bounds } from '@basemaps/geo'; +import pLimit from 'p-limit'; + +/** + * Parses a source path directory topographic maps tiffs and writes out a directory structure + * of StacItem and StacCollection files to the target path. + * + * @param source: Location of the source files + * @example s3://linz-topographic-upload/topographic/TopoReleaseArchive/NZTopo50_GeoTif_Gridless/ + * + * @param target: Location of the target path + */ +export const TopoReliefShadeCreationCommand = command({ + name: 'cogify-topo-reliefshade', + version: CliInfo.version, + description: 'List input topographic relief shade files, standarised the files and create Stacs.', + args: { + ...logArguments, + title: option({ + type: optional(string), + long: 'title', + description: 'Imported imagery title. By default, the title is derived from the map series name', + }), + target: option({ + type: UrlFolder, + long: 'target', + description: 'Target location for the output files', + }), + tempLocation: option({ + type: UrlFolder, + long: 'temp-location', + description: 'Temporary location for intermediate files', + }), + source: option({ + type: UrlFolder, + long: 'source', + description: 'Location of the source files', + }), + }, + async handler(args) { + const logger = getLogger(this, args, 'cli-raster'); + const Q = pLimit(10); + // const startTime = performance.now(); + logger.info('TopoCogify:Start'); + await mkdir(args.target, { recursive: true }); + await mkdir(args.tempLocation, { recursive: true }); + await mkdir(new URL('./source', args.tempLocation), { recursive: true }); + await mkdir(new URL('./target', args.tempLocation), { recursive: true }); + + const items: StacItem[] = []; + const processed: URL[] = []; + const tasks: Array> = []; + for await (const source of fsa.list(args.source)) { + if (source.href.endsWith('.tif') || source.href.endsWith('.tiff')) { + tasks.push( + Q(async () => { + logger.info({ source: source.href }, 'ProcessReliefShade:Start'); + // Download tiff and tfw to temp location for processing + const downloadTiff = new URL(`./source/${basename(source.pathname)}`, args.tempLocation); + await fsa.write(downloadTiff, fsa.readStream(source)); + + // Prepare the bounds from the TFW file + const tfw = await loadTfw(source, logger); + const x1 = tfw.origin.x; + const y1 = tfw.origin.y; + const bounds = new Bounds(x1, y1, tfw.scale.x * 2400, tfw.scale.y * 3600); + const target = new URL(`./target/${basename(source.href)}`, args.tempLocation); + const command = gdalBuildTopoReliefShadeCommands(downloadTiff, target, bounds); + await new GdalRunner(command).run(logger); + processed.push(target); + + logger.info({ source: source.href, target: target.href }, 'ProcessReliefShade:End'); + + const filename = source.pathname.split('/').pop(); + if (filename == null) { + logger.warn({ source }, 'Filename not found in URL'); + return; + } + const id = filename?.split('.')[0]; + const item = await createStacItem(id, target, bounds, logger); + items.push(item); + const tiff = await new Tiff(fsa.source(target)).init(); + const image = tiff.images[0]; + + logger.info({ source: source.href, resolution: image.resolution }, 'ReliefShadeResolution'); + }), + ); + } + } + await Promise.all(tasks); + + logger.info({ items: items.length }, 'WriteTargets:Start'); + const collection = CreateStacCollection(items, logger); + await Promise.all([ + processed.map((c) => fsa.write(new URL(`./${basename(c.pathname)}`, args.target), fsa.readStream(c))), + items.map((item) => + fsa.write(new URL(`./${item.id}.json`, args.target), JSON.stringify(item, null, 2)), + ), + fsa.write(new URL('./collection.json', args.target), JSON.stringify(collection, null, 2)), + ]); + logger.info({ items: items.length }, 'WriteTargets:End'); + }, +}); + +export type TfwParseResult = { + scale: { x: number; y: number }; + origin: { x: number; y: number }; +}; + +/** + * Attempt to load a tiff world file and return parsed values + * + * + * @param imageLoc Location of TIFF file + * @returns + */ +export async function loadTfw(imageLoc: URL, logger: LogType): Promise { + const baseLocation = replaceUrlPathPattern(imageLoc, new RegExp('\\.tiff?$', 'i')); + + const tfwVariants = ['.tfw', '.TFW', '.Tfw']; // add more if needed + let tfwData; + for (const tfwExtension of tfwVariants) { + const candidateTfwLocation = fsa.toUrl(baseLocation.href + tfwExtension); + try { + tfwData = await fsa.read(candidateTfwLocation); + logger.info({ tfwUrl: candidateTfwLocation.href }, 'TFWFound'); + break; + } catch (err) { } + } + + if (!tfwData) { + throw new Error(`Unable to find TFW file for image: ${imageLoc.href} with base location: ${baseLocation.href} and extensions: ${tfwVariants.join(', ')}`); + } + return parseTfw(String(tfwData)); +} + +/** + * Attempt to parse a tiff world file + * + * + * @param data Raw TFW file + * @returns + */ +export function parseTfw(data: string): TfwParseResult { + const parts = data.split('\n'); + if (parts.length < 6) throw new Error('TFW: Not enough points'); + const scaleX = Number(parts[0]); + const scaleY = Number(parts[3]); + if (Number.isNaN(scaleX) || Number.isNaN(scaleY)) throw new Error('TFW: Invalid scales: ' + data); + + const rotationX = Number(parts[1]); + const rotationY = Number(parts[2]); + if (rotationX !== 0 || rotationY !== 0) throw new Error('TFW: Rotation must be zero'); + + const originX = Number(parts[4]); + const originY = Number(parts[5]); + if (Number.isNaN(originX) || Number.isNaN(originY)) throw new Error('TFW: Invalid origins: ' + data); + return { scale: { x: scaleX, y: scaleY }, origin: { x: originX - scaleX / 2, y: originY - scaleY / 2 } }; +} + +/** + * Replace a pattern in a URL, typically used to remove `.tiff` or `.tif` extensions. + * + * @param location (URL) to modify + * @param pattern to replace + * @param replaceValue to replace the pattern with, defaults to an empty string + * + * @returns modified location as a new URL object + */ +export function replaceUrlPathPattern(location: URL, pattern: RegExp, replaceValue: string = ''): URL { + const modifiedLocation = new URL(location); + modifiedLocation.pathname = modifiedLocation.pathname.replace(pattern, replaceValue); + return modifiedLocation; +} + +async function createStacItem( + id: string, + source: URL, + bounds: Bounds, + logger: LogType, +): Promise { + // Create stac item + logger.info({ id }, 'Charts:CreateStacItem'); + const item: StacItem = { + id, + type: 'Feature', + collection: CliId, + stac_version: '1.0.0', + stac_extensions: [], + geometry: { type: 'Polygon', coordinates: bounds.toPolygon() }, + bbox: bounds.toBbox(), + links: [ + { href: `./${id}.json`, rel: 'self' }, + { href: './collection.json', rel: 'collection' }, + { href: './collection.json', rel: 'parent' }, + { + href: urlToString(source), + rel: 'source', + type: 'image/tiff; application=geotiff;', + }, + ], + properties: { + datetime: CliDate, + 'proj:epsg': 2193, + }, + assets: { + 'data': { + href: `./${id}.tiff`, + type: 'image/tiff; application=geotiff;', + }, + } + } + + return item; +} + +function CreateStacCollection(items: StacItem[], logger: LogType): StacCollection { + // Create stac collection + logger.info({ CliId, items: items.length }, 'Charts:CreateStacCollection'); + const collection: StacCollection = { + id: CliId, + type: 'Collection', + stac_version: '1.0.0', + stac_extensions: [], + license: 'CC-BY-4.0', + title: `New Zealand Topo50 Reliefshade`, + description: `New Zealand Topo50 Reliefshade derived from LINZ's Topo50 mapsheets.`, + extent: { + spatial: { bbox: items.map((i) => i.bbox as number[]) as SpatialExtents }, + temporal: { interval: [[CliDate, null]] }, + }, + links: [ + { rel: 'self', href: './collection.json', type: 'application/json' }, + ...items.map((item) => ({ + href: `./${item.id}.json`, + rel: 'item', + type: 'application/json', + })), + ], + }; + + for (const item of items) { + collection.extent.spatial.bbox.push(item.bbox!); + } + + return collection; +} \ No newline at end of file diff --git a/packages/cli-raster/src/cogify/cli/cli.topo.ts b/packages/cli-raster/src/cogify/cli/cli.topo.ts index 7423061262..932faffb55 100644 --- a/packages/cli-raster/src/cogify/cli/cli.topo.ts +++ b/packages/cli-raster/src/cogify/cli/cli.topo.ts @@ -31,8 +31,8 @@ export interface TopoCreationContext { logger: LogType; } -const Format = ['gridded', 'gridless']; -export type Format = 'gridded' | 'gridless'; +const Format = ['gridded', 'gridless', 'hillshade']; +export type Format = 'gridded' | 'gridless'| 'hillshade'; const MapSeries = ['topo25', 'topo50', 'topo250']; export type MapSeries = 'topo25' | 'topo50' | 'topo250'; diff --git a/packages/cli-raster/src/cogify/gdal/gdal.command.ts b/packages/cli-raster/src/cogify/gdal/gdal.command.ts index 85ae6abea3..fa93e50e3e 100644 --- a/packages/cli-raster/src/cogify/gdal/gdal.command.ts +++ b/packages/cli-raster/src/cogify/gdal/gdal.command.ts @@ -1,5 +1,5 @@ import { Rgba } from '@basemaps/config'; -import { Epsg, EpsgCode, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; +import { Bounds, Epsg, EpsgCode, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; import { urlToString } from '@basemaps/shared'; import { PresetName, Presets } from '../../preset.js'; @@ -223,6 +223,41 @@ export function gdalBuildTopoRasterCommands( return command; } +/** + * Build a topographic mapsheet relief shade cog + * + * This is specific configuration to LINZ's topo50 relief shade mapsheets + */ +export function gdalBuildTopoReliefShadeCommands( + sourceTiff: URL, + targetTiff: URL, + bounds: Bounds, +): GdalCommand { + const command: GdalCommand = { + command: 'gdal_translate', + output: targetTiff, + args: [ + ['-q'], // Supress non-error output + ['-stats'], // Force stats (re)computation + ['-of', 'COG'], // Output format + ['-expand', 'gray'], + ['-oo', 'GEOREF_SOURCES=NONE'], // Ignore georef sources as the relief shade tiffs have incorrect georef sources that cause gdalwarp to fail + ['-a_srs', `EPSG:2193`], + ['-a_ullr', bounds.x, bounds.y, bounds.right, bounds.bottom], + ['-co', 'COMPRESS=ZSTD'], + ['-co', 'LEVEL=17'], + ['-co', 'PREDICTOR=2'], + urlToString(sourceTiff), + urlToString(targetTiff), + ] + .filter((f) => f != null) + .flat() + .map(String), + }; + + return command; +} + /** * Standardized gdalwarp command for charts mapsheets * From afde1144109048e08a04445f4f2fb642e13868b0 Mon Sep 17 00:00:00 2001 From: Wentao Kuang Date: Thu, 11 Jun 2026 17:30:06 +1200 Subject: [PATCH 2/3] Crop topo50 map covers --- packages/cli-raster/src/cover-final.ts | 391 +++++++++++++++++++++++++ packages/cli-raster/src/cover-fix.ts | 99 +++++++ packages/cli-raster/src/cover-map.ts | 95 ++++++ 3 files changed, 585 insertions(+) create mode 100644 packages/cli-raster/src/cover-final.ts create mode 100644 packages/cli-raster/src/cover-fix.ts create mode 100644 packages/cli-raster/src/cover-map.ts diff --git a/packages/cli-raster/src/cover-final.ts b/packages/cli-raster/src/cover-final.ts new file mode 100644 index 0000000000..69750cb823 --- /dev/null +++ b/packages/cli-raster/src/cover-final.ts @@ -0,0 +1,391 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import execa from "execa"; +import fg from "fast-glob"; +import * as cheerio from "cheerio"; + +const execFileAsync = promisify(execFile); + +const INPUT_DIR = "./eps"; +const HILLSHADE_DIR = "./hillshade"; +const OUTPUT_DIR = "./out"; + +const EPS_OUT_DIR = path.join(OUTPUT_DIR, "eps"); +const PDF_OUT_DIR = path.join(OUTPUT_DIR, "pdf"); +const SVG_OUT_DIR = path.join(OUTPUT_DIR, "svg"); +const PNG_OUT_DIR = path.join(OUTPUT_DIR, "png"); +const VECTOR_OUT_DIR = path.join(OUTPUT_DIR, "vector-only"); + +const GS = "gs"; +const INKSCAPE = "inkscape"; + +// hillshade opacity +// 1.0 = original +// 0.6 = lighter +// 0.35 = very light +const IMAGE_OPACITY = 1.0; + +// bottom/index orange box grey K value +// 0.30 = 30% black +// 0.18 = lighter grey +const INDEX_BOX_GREY_K = 0.30; + +const fontMap: Record = { + "ATTriumvirateMou-CondBold": "NimbusSansNarrow-Bold", + "ATTriumvirateMou-Cond": "NimbusSansNarrow-Regular", +}; + +const HILLSHADE_SUFFIXES = [ + "_hs.tif", + "_hs.tiff", + "_hillshade.tif", + "_hillshade.tiff", +]; + +async function ensureDirs() { + await fs.mkdir(EPS_OUT_DIR, { recursive: true }); + await fs.mkdir(PDF_OUT_DIR, { recursive: true }); + await fs.mkdir(SVG_OUT_DIR, { recursive: true }); + await fs.mkdir(PNG_OUT_DIR, { recursive: true }); + await fs.mkdir(VECTOR_OUT_DIR, { recursive: true }); +} + +function parseAndSplitEps(buffer: Buffer) { + const isBinaryEps = + buffer[0] === 0xc5 && + buffer[1] === 0xd0 && + buffer[2] === 0xd3 && + buffer[3] === 0xc6; + + if (!isBinaryEps) { + return { + psText: buffer.toString("latin1"), + tiffBuffer: null as Buffer | null, + }; + } + + const psStart = buffer.readUInt32LE(4); + const psLength = buffer.readUInt32LE(8); + const tiffStart = buffer.readUInt32LE(12); + const tiffLength = buffer.readUInt32LE(16); + + const psText = buffer.subarray(psStart, psStart + psLength).toString("latin1"); + + let tiffBuffer: Buffer | null = null; + + if (tiffLength > 0 && tiffStart > 0) { + tiffBuffer = buffer.subarray(tiffStart, tiffStart + tiffLength); + } + + return { psText, tiffBuffer }; +} + +function removeEmbeddedFont(eps: string, fontName: string): string { + const pattern = new RegExp( + `%%BeginResource: font ${fontName}[\\s\\S]*?%%EndResource\\s*`, + "g", + ); + + return eps.replace(pattern, ""); +} + +function replaceFonts(eps: string, fileName: string): string { + eps = removeEmbeddedFont(eps, "ATTriumvirateMou-CondBold"); + eps = removeEmbeddedFont(eps, "ATTriumvirateMou-Cond"); + + for (const [oldFont, newFont] of Object.entries(fontMap)) { + const count = eps.split(oldFont).length - 1; + console.log(`${fileName}: ${oldFont} matches = ${count}`); + eps = eps.replaceAll(oldFont, newFont); + } + + return eps; +} + +function replaceIndexOrangeBox(eps: string, fileName: string): string { + const fillGrey = `0 0 0 ${INDEX_BOX_GREY_K.toFixed(2)} create_cmyk_color set_solid_fill`; + + let changed = 0; + + const result = eps.replace( + /@rax %Note: Object[\s\S]*?(?=@rax %Note: Object|%%Trailer|$)/g, + (block) => { + const bbox = block.match( + /([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+@E/, + ); + + if (!bbox) return block; + + const left = Number(bbox[1]); + const bottom = Number(bbox[2]); + const right = Number(bbox[3]); + const top = Number(bbox[4]); + + const width = right - left; + const height = top - bottom; + + const isIndexBox = + width > 20 && + width < 220 && + height > 20 && + height < 260 && + bottom > 30 && + top < 420 && + block.includes("PANTONE 151 C"); + + if (!isIndexBox) return block; + + changed++; + + console.log( + `${fileName}: changed orange/index box bbox ${left}, ${bottom}, ${right}, ${top}`, + ); + + return block.replace( + /[0-9.]+\s+\(PANTONE 151 C\)\s+\[\s*65\.0980\s+41\.0000\s+71\.0000\s*\]\s+\/DocLabSpace\s+create_spot_color set_solid_fill/g, + fillGrey, + ); + }, + ); + + if (changed === 0) { + console.warn(`${fileName}: no orange/index box changed`); + } + + return result; +} + +function safeName(file: string) { + return path.basename(file, path.extname(file)).replace(/[^\w.-]+/g, "_"); +} + +function getSheetCode(file: string) { + const base = path.basename(file, path.extname(file)); + const match = base.match(/[A-Z]{2}\d{2}/i); + + if (!match) { + throw new Error(`Cannot find sheet code from filename: ${file}`); + } + + return match[0].toLowerCase(); +} + +async function findHillshade(sheetCode: string) { + for (const suffix of HILLSHADE_SUFFIXES) { + const candidate = path.join(HILLSHADE_DIR, `${sheetCode}${suffix}`); + + try { + await fs.access(candidate); + return candidate; + } catch { + // try next + } + } + + const matches = await fg([`${sheetCode}*.tif`, `${sheetCode}*.tiff`], { + cwd: HILLSHADE_DIR, + absolute: true, + caseSensitiveMatch: false, + }); + + return matches[0] ?? null; +} + +async function epsToPdf(inputEps: string, outputPdf: string) { + await execFileAsync(GS, [ + "-dBATCH", + "-dNOPAUSE", + "-dSAFER", + "-dEPSCrop", + "-sDEVICE=pdfwrite", + "-dCompatibilityLevel=1.4", + + "-r600", + "-dPDFSETTINGS=/prepress", + "-dColorImageResolution=600", + "-dGrayImageResolution=600", + "-dMonoImageResolution=600", + "-dColorImageDownsampleThreshold=1.0", + "-dGrayImageDownsampleThreshold=1.0", + + "-dEmbedAllFonts=true", + "-dSubsetFonts=false", + + `-sOutputFile=${outputPdf}`, + inputEps, + ]); +} + +async function pdfToSvg(inputPdf: string, outputSvg: string) { + await execFileAsync(INKSCAPE, [ + inputPdf, + "--export-type=svg", + "--export-dpi=600", + `--export-filename=${outputSvg}`, + ]); +} + +async function createPngKeepOriginalColour(inputTif: string, outputPng: string) { + await execa("gdal_translate", [ + "-of", + "PNG", + + // no -scale, keeps hillshade colour/brightness + inputTif, + outputPng, + ]); +} + +function getImageTags(svgText: string) { + return [...svgText.matchAll(/|<\/image>)/gi)]; +} + +async function replaceMainImageWithEmbeddedPng( + svgPath: string, + pngPath: string, + outputSvg: string, +) { + let svgText = await fs.readFile(svgPath, "utf8"); + + const pngBuffer = await fs.readFile(pngPath); + const embeddedPng = `data:image/png;base64,${pngBuffer.toString("base64")}`; + + const imageMatches = getImageTags(svgText); + + console.log(`Found : ${imageMatches.length}`); + + if (imageMatches.length === 0) { + throw new Error(`No found in ${svgPath}`); + } + + const firstMatch = imageMatches[0]; + const firstImage = firstMatch[0]; + + let newFirstImage = firstImage + .replace(/\s(?:xlink:)?href="[^"]*"/gi, "") + .replace(/\spreserveAspectRatio="[^"]*"/gi, "") + .replace(/\sopacity="[^"]*"/gi, ""); + + if (newFirstImage.endsWith("/>")) { + newFirstImage = newFirstImage.replace( + /\s*\/>$/, + ` href="${embeddedPng}" preserveAspectRatio="none" opacity="${IMAGE_OPACITY}" />`, + ); + } else { + newFirstImage = newFirstImage.replace( + /<\/image>$/i, + ` href="${embeddedPng}" preserveAspectRatio="none" opacity="${IMAGE_OPACITY}">`, + ); + } + + svgText = + svgText.slice(0, firstMatch.index!) + + newFirstImage + + svgText.slice(firstMatch.index! + firstImage.length); + + const updatedImageMatches = getImageTags(svgText); + + for (let i = updatedImageMatches.length - 1; i >= 0; i--) { + if (i === 0) continue; + + const match = updatedImageMatches[i]; + + svgText = + svgText.slice(0, match.index!) + + "" + + svgText.slice(match.index! + match[0].length); + } + + await fs.writeFile(outputSvg, svgText, "utf8"); + + const check = await fs.readFile(outputSvg, "utf8"); + + console.log("Output contains embedded PNG:", check.includes("data:image/png;base64")); + console.log("Output contains TIF:", check.includes(".tif") || check.includes(".tiff")); + console.log("Output contains linked PNG:", check.includes(".png")); +} + +async function writeVectorOnly(svgPath: string, outputSvg: string) { + const svgText = await fs.readFile(svgPath, "utf8"); + const $ = cheerio.load(svgText, { xmlMode: true }); + + $("image").remove(); + + await fs.writeFile(outputSvg, $.xml()); +} + +async function processEps(fileName: string) { + const inputPath = path.join(INPUT_DIR, fileName); + const baseName = safeName(fileName); + const sheetCode = getSheetCode(fileName); + + console.log(`\nProcessing: ${baseName}`); + console.log(`Sheet code: ${sheetCode}`); + + const outputEps = path.join(EPS_OUT_DIR, `${baseName}.eps`); + const outputPdf = path.join(PDF_OUT_DIR, `${baseName}.pdf`); + const rawSvg = path.join(SVG_OUT_DIR, `${baseName}.raw.svg`); + const finalSvg = path.join(SVG_OUT_DIR, `${baseName}.hillshade.svg`); + const vectorOnlySvg = path.join(VECTOR_OUT_DIR, `${baseName}.vector-only.svg`); + const pngHillshade = path.join(PNG_OUT_DIR, `${sheetCode}_hs_600dpi.png`); + + const inputBuffer = await fs.readFile(inputPath); + const { psText } = parseAndSplitEps(inputBuffer); + + let eps = psText; + + eps = replaceFonts(eps, fileName); + eps = replaceIndexOrangeBox(eps, fileName); + + await fs.writeFile(outputEps, eps, "latin1"); + + await epsToPdf(outputEps, outputPdf); + await pdfToSvg(outputPdf, rawSvg); + + const hillshadePath = await findHillshade(sheetCode); + + if (!hillshadePath) { + console.warn(`${fileName}: no hillshade found, writing raw SVG only`); + await fs.copyFile(rawSvg, finalSvg); + await writeVectorOnly(rawSvg, vectorOnlySvg); + return; + } + + console.log(`Hillshade: ${hillshadePath}`); + + await createPngKeepOriginalColour(hillshadePath, pngHillshade); + + await replaceMainImageWithEmbeddedPng( + rawSvg, + pngHillshade, + finalSvg, + ); + + await writeVectorOnly(rawSvg, vectorOnlySvg); + + console.log(`✓ EPS: ${outputEps}`); + console.log(`✓ PDF: ${outputPdf}`); + console.log(`✓ Raw SVG: ${rawSvg}`); + console.log(`✓ Final SVG: ${finalSvg}`); + console.log(`✓ Vector only: ${vectorOnlySvg}`); + console.log(`✓ PNG: ${pngHillshade}`); +} + +async function main() { + await ensureDirs(); + + const files = await fs.readdir(INPUT_DIR); + + for (const file of files) { + if (file.toLowerCase().endsWith(".eps")) { + await processEps(file); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); \ No newline at end of file diff --git a/packages/cli-raster/src/cover-fix.ts b/packages/cli-raster/src/cover-fix.ts new file mode 100644 index 0000000000..379c4ff0e9 --- /dev/null +++ b/packages/cli-raster/src/cover-fix.ts @@ -0,0 +1,99 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const INPUT_DIR = "./eps"; +const OUTPUT_DIR = "./out"; + +const fontMap: Record = { + "ATTriumvirateMou-CondBold": "NimbusSansNarrow-Bold", + "ATTriumvirateMou-Cond": "NimbusSansNarrow-Regular", +}; + +const replacements: [RegExp, string][] = [ + // Orange: PANTONE 151 C -> light grey for the bottom cover title + [ + /1\.00 \(PANTONE 151 C\) \[ 65\.0980 41\.0000 71\.0000\] \/DocLabSpace\s+create_spot_color set_solid_fill([\s\S]*?10\.04315 422\.31628 m[\s\S]*?@c\s*F)/g, + "0 0 0 0.22 create_cmyk_color set_solid_fill$1", + ], + // Warm red if needed + // [/0 0\.96 1 0 \(PANTONE Warm Red C\)/g, "0 0.85 0.9 0 (PANTONE Warm Red C)"], +]; + +function removeEmbeddedFont(eps: string, fontName: string): string { + const pattern = new RegExp( + `%%BeginResource: font ${fontName}[\\s\\S]*?%%EndResource\\s*`, + "g" + ); + + return eps.replace(pattern, ""); +} + +async function processEps(fileName: string) { + const inputPath = path.join(INPUT_DIR, fileName); + const baseName = fileName.replace(/\.eps$/i, ""); + + const outputEps = path.join(OUTPUT_DIR, `${baseName}.eps`); + const outputPdf = path.join(OUTPUT_DIR, `${baseName}.pdf`); + const outputSvg = path.join(OUTPUT_DIR, `${baseName}.svg`); + + let eps = await fs.readFile(inputPath, "latin1"); + + eps = removeEmbeddedFont(eps, "ATTriumvirateMou-CondBold"); + eps = removeEmbeddedFont(eps, "ATTriumvirateMou-Cond"); + + for (const [oldFont, newFont] of Object.entries(fontMap)) { + const count = eps.split(oldFont).length - 1; + console.log(`${fileName}: ${oldFont} matches after font removal = ${count}`); + eps = eps.replaceAll(oldFont, newFont); + } + + // Colour/text replacements + for (const [from, to] of replacements) { + const count = (eps.match(from) ?? []).length; + console.log(`${fileName}: ${from} = ${count}`); + eps = eps.replace(from, to); + } + + await fs.writeFile(outputEps, eps, "latin1"); + + try { + await execFileAsync("gs", [ + "-dBATCH", + "-dNOPAUSE", + "-dSAFER", + "-dEPSCrop", + "-sDEVICE=pdfwrite", + "-dPDFSETTINGS=/prepress", + `-sOutputFile=${outputPdf}`, + outputEps, + ]); + } catch (error) { + console.error(`Error processing ${fileName} with Ghostscript:`, error); + } + + await execFileAsync("inkscape", [ + outputPdf, + "--export-type=svg", + `--export-filename=${outputSvg}`, + ]); + + console.log(`✓ ${fileName} -> ${outputPdf}`); +} + +async function main() { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + + const files = await fs.readdir(INPUT_DIR); + + for (const file of files) { + if (file.toLowerCase().endsWith(".eps")) { + await processEps(file); + } + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/packages/cli-raster/src/cover-map.ts b/packages/cli-raster/src/cover-map.ts new file mode 100644 index 0000000000..6ac9a6fb2e --- /dev/null +++ b/packages/cli-raster/src/cover-map.ts @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import fg from "fast-glob"; + +const execFileAsync = promisify(execFile); + +const VECTOR_SVG_DIR = "./out/vector-only"; +const OUTPUT_DIR = "./out/bottom-vector-map"; + +const INKSCAPE = "inkscape"; +const GDAL_TRANSLATE = "gdal_translate"; +const EXPORT_DPI = 600; + +// This crop matches your AX31 vector-only file. +// Adjust here if needed. +const CROP = { + x1: 51, + y1: 1064, + x2: 324, + y2: 1473, +}; + +async function ensureDirs() { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); +} + +function baseKey(file: string) { + return path + .basename(file) + .replace(/\.vector-only\.svg$/i, ""); +} + +async function vectorSvgCropToTiff( + vectorSvgPath: string, + outputTiff: string, +) { + const tempPng = outputTiff.replace(/\.tif$/i, ".temp.png"); + + await execFileAsync(INKSCAPE, [ + vectorSvgPath, + "--export-type=png", + `--export-dpi=${EXPORT_DPI}`, + `--export-area=${CROP.x1}:${CROP.y1}:${CROP.x2}:${CROP.y2}`, + `--export-filename=${tempPng}`, + ]); + + await execFileAsync(GDAL_TRANSLATE, [ + "-of", + "GTiff", + "-co", + "COMPRESS=LZW", + "-co", + "TILED=YES", + tempPng, + outputTiff, + ]); + + await fs.rm(tempPng, { force: true }); +} + +async function main() { + await ensureDirs(); + + const vectorFiles = await fg(["*.vector-only.svg"], { + cwd: VECTOR_SVG_DIR, + absolute: true, + }); + + console.log(`Found vector-only SVG: ${vectorFiles.length}`); + + for (const vectorSvgPath of vectorFiles) { + const key = baseKey(vectorSvgPath); + + const outputTiffPath = path.join( + OUTPUT_DIR, + `${key}.bottom-vector-map.tif`, + ); + + console.log(`\nProcessing ${key}`); + + await vectorSvgCropToTiff( + vectorSvgPath, + outputTiffPath, + ); + + console.log(`✓ ${outputTiffPath}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); \ No newline at end of file From b82062ed1acbdc51de1c64b30d8c7b4db797e3ff Mon Sep 17 00:00:00 2001 From: Wentao Kuang Date: Wed, 17 Jun 2026 11:25:36 +1200 Subject: [PATCH 3/3] rename --- packages/cli-raster/src/{cover-final.ts => cover.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/cli-raster/src/{cover-final.ts => cover.ts} (100%) diff --git a/packages/cli-raster/src/cover-final.ts b/packages/cli-raster/src/cover.ts similarity index 100% rename from packages/cli-raster/src/cover-final.ts rename to packages/cli-raster/src/cover.ts