diff --git a/packages/bake/authoring/ngc-3132/grid-fit.mts b/packages/bake/authoring/ngc-3132/grid-fit.mts
index 638dd4f9b33..5830fe02da5 100644
--- a/packages/bake/authoring/ngc-3132/grid-fit.mts
+++ b/packages/bake/authoring/ngc-3132/grid-fit.mts
@@ -59,10 +59,10 @@ const file = await readFile(resolve(sourceDirectory, 'source.jpg')), tags = file
const tag = (name: string) => { const found = new RegExp(`([\\s\\S]*?)`, 'u').exec(tags) ?? new RegExp(`avm:${name}="([^"]*)"`, 'u').exec(tags); if (!found) throw new Error(`source.jpg has no avm:${name}.`); return found[1]!.replace(/<[^>]+>/gu, ' ').trim().split(/\s+/u).map(Number); };
const pixelArcsec = Math.abs(tag('Spatial.Scale')[0]!) * 3600, rotation = tag('Spatial.Rotation')[0]! * rad;
const full = await sharp(file).removeAlpha().raw().toBuffer({ resolveWithObject: true }), fullWidth = full.info.width, fullHeight = full.info.height;
-// Where the tags alone put the star: the recipe's target, from the tags' reference place and pixel (counted from the bottom).
+// Where the tags alone put the star: the recipe's target, from the tags' reference place and pixel (counted from the bottom left, the first pixel's middle at 1, as FITS counts).
const target = (JSON.parse(await readFile(resolve(sourceDirectory, 'recipe.json'), 'utf8')) as { target: { centerRaDeg: number; centerDecDeg: number } }).target, reference = tag('Spatial.ReferenceValue'), referencePixel = tag('Spatial.ReferencePixel'), degrees = Math.abs(tag('Spatial.Scale')[0]!);
const eastPixels = (target.centerRaDeg - reference[0]!) * Math.cos(target.centerDecDeg * rad) / degrees, northPixels = (target.centerDecDeg - reference[1]!) / degrees;
-const tagged = [referencePixel[0]! - eastPixels * Math.cos(rotation) - northPixels * Math.sin(rotation), fullHeight - (referencePixel[1]! - eastPixels * Math.sin(rotation) + northPixels * Math.cos(rotation))] as const, within = Math.round(STAR_WITHIN_ARCSEC / pixelArcsec);
+const tagged = [referencePixel[0]! - 1 - eastPixels * Math.cos(rotation) - northPixels * Math.sin(rotation), fullHeight - (referencePixel[1]! - eastPixels * Math.sin(rotation) + northPixels * Math.cos(rotation))] as const, within = Math.round(STAR_WITHIN_ARCSEC / pixelArcsec);
// The star: the largest patch of saturated pixels that starts within that reach of the place.
const saturated = (p: number) => full.data[3 * p]! >= 250 && full.data[3 * p + 1]! >= 250 && full.data[3 * p + 2]! >= 250, seen = new Uint8Array(fullWidth * fullHeight); let star = { pixels: 0, x: 0, y: 0, from: 0 };
for (let y = Math.max(0, Math.round(tagged[1] - within)); y <= Math.min(fullHeight - 1, tagged[1] + within); y++) for (let x = Math.max(0, Math.round(tagged[0] - within)); x <= Math.min(fullWidth - 1, tagged[0] + within); x++) { const start = y * fullWidth + x; if (seen[start] || !saturated(start) || Math.hypot(x - tagged[0], y - tagged[1]) > within) continue; let pixels = 0, sumX = 0, sumY = 0; const stack = [start]; seen[start] = 1;
diff --git a/packages/telescope-cli/AGENTS.md b/packages/telescope-cli/AGENTS.md
index 8fefa81a04d..c752692a5b6 100644
--- a/packages/telescope-cli/AGENTS.md
+++ b/packages/telescope-cli/AGENTS.md
@@ -39,6 +39,11 @@ own through `workspace-commands/new-object.mts`, `new-object-cli.mts` is the sta
The generated solar geometry (`src/platform/solar-geometry.mts`) stays generated: the entries load it with `solar-epoch.mts` and
pass the epoch down, so no module here imports it.
+A published picture as one more dataset of a page is `src/new-object/pictures/`: `--from-esa` and a spec's `pictures`. `esa-image.mts` reads
+ESA's picture pages and the sky tags in a JPEG, `registration.mts` owns the arithmetic from those tags and one pixel to a recipe's
+observation, plane and rim, `picture-bank.mts` writes one entry's records from the bank it is like, and `pictures.mts` drafts, writes
+and bakes. It never writes a sentence a reader sees and never picks a value for a color: those are the spec's, written by a person.
+
The star survey behind `telescope stars GALAXY` is `src/stars/`. It and the generator's `--from-table` route
(`src/new-object/archives/tables/table-stars.mts`) read VizieR's table metadata and SIMBAD through the same two modules,
`vizier-tables.mts` and `simbad-tap.mts` beside it; a star class is a branch of SIMBAD's own type tree, never a
diff --git a/packages/telescope-cli/README.md b/packages/telescope-cli/README.md
index f7aff20268c..6df2ac332b7 100644
--- a/packages/telescope-cli/README.md
+++ b/packages/telescope-cli/README.md
@@ -38,6 +38,8 @@ In a terminal, `explore` shows the observations, unknown metadata, unsupported r
`telescope simulations OBJECT` lists the Zenodo dataset records that name the object and speak of a simulation or a model, each with its license, size and files. It downloads nothing, and a listed record is a lead. To show one, read its paper and write an entry for `node packages/telescope-cli/src/new-object/new-object-cli.mts --simulation entries.json` ([simulation-dataset.mts](src/new-object/simulation/simulation-dataset.mts) documents the entry). That command checks the record's license, that it names the object and that it lists the file, and writes the dataset with its labels. It never fetches the release whole: two range requests bring the file's first bytes (its header and coordinate variables) and the bytes of the selected grid into the body's source directory, each declared with its byte range in the source manifest. A 3.5 GB run costs about 130 kB. The [published simulations](../../.agents/skills/celestial-skill/references/scientific-faithfulness.md#published-simulations) rule gives the conditions.
+`telescope new-object --from-esa PAGE_ID=PICTURE_URL... --out SPEC.json` drafts a published picture as one more dataset of a page that already shows a shaped layer bank: a picture ESA publishes for Hubble or Webb (`m57=https://esawebb.org/images/weic2320c/`). It reads the picture's page (title, credit, release date, colors) and the sky tags in its JPEG, finds the star the picture shows where the tags put the page's place, and names the bank the picture will lie on like: the one the page opens on. A picture that does not hold the page's place is refused. A person then writes the dataset's sentences and, under `geometry`, what this picture's light takes on those walls: printed values, each with its source. `telescope new-object SPEC.json` writes the new bank's records, the picture, the page's dataset and a README whose measured lines are filled in; `--bake` bakes the banks and their pages in one chain. A spec can be run again after a change, and the README a person wrote is kept. The entry's fields are in [picture-bank.mts](src/new-object/pictures/picture-bank.mts).
+
`telescope stars GALAXY` counts the stars SIMBAD lists inside a galaxy's catalogued outline by class (Cepheids, RR Lyrae stars, long-period variables, supergiants), names the papers they come from, and reads VizieR's tables of those papers for what a placed star needs: a period and a position of its own. It also names the single stars of no class that SIMBAD holds there with no parallax and no proper motion, the most cited first: a star one paper studied alone (M51-DS1) is often typed a plain star, and is drafted by hand from that paper. Last, it asks the papers API (OpenAlex, with arXiv when OpenAlex's daily budget is spent; the same index `telescope papers` reads) for the works whose title or abstract names the galaxy and a kind of star, those with the star in the title first: SIMBAD and VizieR take months to hold a new paper and never hold some, so a 2026 paper of Cepheids in M61 and M77 and a 2003 paper of one star in NGC 253's halo were found only there. It downloads nothing, and a star SIMBAD lists inside the outline may still be in front of the galaxy. A table it calls ready drafts a star: `telescope new-object --from-table cepheid:m81=J/ApJ/743/176/table1 --out spec.json` writes the spec of one Cepheid of that table, placed where its sight line crosses the disc the galaxy is drawn as ([table-stars.mts](src/new-object/archives/tables/table-stars.mts) documents the row choices). A table that gives every row its galaxy's centre, as a reanalysis does, places its stars at SIMBAD's position of the SIMBAD name CDS added to each row. A table that lists each star by its detector pixel (a chip, X and Y, as the Hubble Cepheid papers do) needs the exposure those pixels were measured on, which only the paper says: `--from-table "cepheid:m66=J/ApJ/529/723/appen#exposure=mast:HST/product/u35i0101r_c0m.fits,firstPixel=1"` reads that chip's header from MAST by byte range (11,520 bytes of a 10 MB file) and places the star where the header's world coordinates put the pixel. `firstPixel` is the coordinate the paper's software gives the centre of the first pixel: 1 for DAOPHOT and ALLFRAME, 0.5 for HSTphot. The position is Hubble's pointing as archived, not registered against a catalogue: on the one exposure checked, a supernova with a catalogued position fell 1.2″ from it ([image-pixel.mts](src/new-object/archives/images/image-pixel.mts)); a hand-written spec does the same with `position.archive: "mast"`. Coordinates a paper prints in a table that neither VizieR nor SIMBAD holds yet are used as printed with `position.archive: "paper"`: the paper is cited by its DOI and nothing is archived.
When you know the scientific acceptance criteria, save an explicit request:
diff --git a/packages/telescope-cli/src/cli.test.mts b/packages/telescope-cli/src/cli.test.mts
index 11f592e2d63..0521929d4dd 100644
--- a/packages/telescope-cli/src/cli.test.mts
+++ b/packages/telescope-cli/src/cli.test.mts
@@ -83,6 +83,8 @@ test('new-object bakes a spec after generating it, or objects already in the tre
const draft=parseCli(['new-object','--from-debcat','47 Tuc V69','CM Dra','--out','spec.json']);assert.equal(draft.command,'new-object');if(draft.command!=='new-object')return;
assert.deepEqual([draft.from,draft.names],['debcat',['47 Tuc V69','CM Dra']]);
assert.throws(()=>parseCli(['new-object','--from-apokasc','--out','spec.json']),/--from-apokasc NAME\.\.\. --out SPEC\.json/u);
+ const picture=parseCli(['new-object','--from-esa','m57=https://esawebb.org/images/weic2320c/','--out','spec.json']);assert.equal(picture.command,'new-object');if(picture.command!=='new-object')return;
+ assert.deepEqual([picture.from,picture.names],['esa',['m57=https://esawebb.org/images/weic2320c/']]);
});
test('local import has one bounded data-only entry point',()=>{
const parsed=parseCli(['import','spec.json','--out','run','--json']);assert.equal(parsed.command,'import');if(parsed.command!=='import')return;
diff --git a/packages/telescope-cli/src/help.mts b/packages/telescope-cli/src/help.mts
index 8d06858ec53..555fa4d9eb3 100644
--- a/packages/telescope-cli/src/help.mts
+++ b/packages/telescope-cli/src/help.mts
@@ -20,7 +20,7 @@ Human entry points:
telescope leads TARGET | --class CLASS [--json] [--out DIRECTORY]
telescope stars GALAXY [--json] [--out DIRECTORY]
telescope new-object SPEC.json [--check] [--skip-existing] [--json]
- telescope new-object --from-archive HOST... | --from-debcat SYSTEM... | --from-apokasc KIC... | --from-cepheids NAME... | --from-k2 EPIC... | --from-tess TIC... | --from-gaia SOURCE_ID... | --from-hipparcos HIP... | --from-iau all|NAME... | --from-chara HD... | --from-npoi HD... | --from-sh0es HOST[/ID]... | --from-m31cepheids all|V1|ID... | --from-m33cepheids all|ID... | --from-table CLASS:GALAXY=TABLE[#ROW]... --out SPEC.json
+ telescope new-object --from-archive HOST... | --from-debcat SYSTEM... | --from-apokasc KIC... | --from-cepheids NAME... | --from-k2 EPIC... | --from-tess TIC... | --from-gaia SOURCE_ID... | --from-hipparcos HIP... | --from-iau all|NAME... | --from-chara HD... | --from-npoi HD... | --from-sh0es HOST[/ID]... | --from-m31cepheids all|V1|ID... | --from-m33cepheids all|ID... | --from-table CLASS:GALAXY=TABLE[#ROW]... | --from-esa PAGE_ID=PICTURE_URL... --out SPEC.json
telescope ascl SOFTWARE [--json]
telescope ascl --product PRODUCT.json|RUN/pick-N/result.json [--json]
telescope wwt-fits EXPLORE.json --pick N --level N --x X --y Y --out DIRECTORY
@@ -50,7 +50,10 @@ Only prose is left marked TODO(new-object), unless the spec carries drafted text
NASA Exoplanet Archive's default parameter sets (their transiting planets, with drafted text); --from-debcat drafts both stars of
an eclipsing binary from DEBCat, --from-apokasc a Kepler-field giant from APOKASC-3, --from-cepheids a Cepheid from Groenewegen
(2013), --from-k2 a K2-field giant and --from-tess a TESS giant from Khan et al. (2023), --from-gaia any star from Gaia DR3 FLAME, --from-hipparcos a Hipparcos star from the model-atmosphere fit to its light (McDonald et al. 2017), --from-iau the stars the IAU has named that the universe lacks, each by the first of those routes that measures it, --from-chara a nearby star whose disc CHARA measured (Boyajian et al. 2012), --from-npoi a bright star whose disc the NPOI measured (Baines et al. 2018, 2021), --from-sh0es the Cepheids Hubble found in a SN Ia host galaxy (Hoffmann et al. 2016), --from-m31cepheids Hubble's V1 and the Cepheids Hubble measured in Andromeda (Li et al. 2021), --from-m33cepheids those it measured in Triangulum (Breuval et al. 2023), --from-table a star of another galaxy from any VizieR table that lists it with a period (cepheid:m81=J/ApJ/743/176/table1), placed in its galaxy by the table's position, by SIMBAD's, or by the table's detector pixel on the exposure the request names (#exposure=mast:HST/product/FILE.fits,firstPixel=N), each naming what the spec still needs from the paper; --check runs the bake's first steps on
-what was generated; --skip-existing leaves objects already in the universe alone. The spec format is in packages/telescope-cli/src/new-object/spec.mts.
+what was generated; --skip-existing leaves objects already in the universe alone. --from-esa drafts a picture ESA publishes for Hubble or Webb as one more
+dataset of a page that shows a shaped layer bank (m57=https://esawebb.org/images/weic2320c/): it reads the picture's page and the sky tags in its JPEG, finds the
+star the picture shows at the page's place, and leaves the sentences and what each color takes on the bank's walls to write; a spec of pictures writes each
+picture's bank and its page's dataset, may be run again, and bakes with SPEC.json --bake. The spec format is in packages/telescope-cli/src/new-object/spec.mts.
Papers lists up to 20 OpenAlex works that name the target, by any spelling of its catalogue names, in their title or
abstract, with the instrument and any one of the --about phrases (singular or plural); --fulltext asks for the works whose
indexed full text names them. It uses arXiv's Atom API when OpenAlex is unavailable or its daily budget is spent, and the
diff --git a/packages/telescope-cli/src/new-object/cli.mts b/packages/telescope-cli/src/new-object/cli.mts
index 14a87c76bb1..5f61dc624f4 100644
--- a/packages/telescope-cli/src/new-object/cli.mts
+++ b/packages/telescope-cli/src/new-object/cli.mts
@@ -22,6 +22,8 @@ export function parseNewObjectOptions(value:unknown):NewObjectOptions{
return {...string('spec'),...strings('ids'),...string('from'),...strings('names'),...string('out'),check:flag('check'),bake:flag('bake'),refresh:flag('refresh'),skipExisting:flag('skipExisting'),json:flag('json')};
}
+const holdsPictures=async(spec:string)=>{const {readFile}=await import('node:fs/promises'),value:unknown=JSON.parse(await readFile(spec,'utf8'));return typeof value==='object'&&value!==null&&'pictures' in value;};
+
/** The result text and exit code for one parsed `new-object` command. */
export async function newObjectCommand(options:NewObjectOptions,root:string,stderr:(text:string)=>void):Promise<{readonly text:string;readonly code:number}>{
let text:string,code:number;
@@ -38,6 +40,11 @@ export async function newObjectCommand(options:NewObjectOptions,root:string,stde
}else if(options.from){
const result=await writeDrafts(options.from,options.names??[],options.out!,{root,archive:liveArchive,progress:line=>stderr(`${line}\n`)});
text=options.json?`${JSON.stringify(result)}\n`:`${result.report.join('\n')}\n${result.entries} entries written to ${result.path}\n`;code=result.entries?0:3;
+ }else if(await holdsPictures(options.spec!)){
+ // A spec of published pictures: each one a dataset on a page that shows a shaped layer bank (pictures/pictures.mts).
+ const {runPictures,bakePictures,formatPictures}=await import('./pictures/pictures.mts'),results=await runPictures(options.spec!,{root,archive:liveArchive,progress}),good=results.filter(result=>!result.failed);
+ const baked=options.bake&&good.length?await bakePictures(good,{root,progress}):true;
+ text=options.json?`${JSON.stringify(results)}\n`:formatPictures(results,options.spec!,baked&&options.bake&&good.length>0);code=baked&&good.length===results.length?0:1;
}else{
const results=await runNewObject(options.spec!,{root,progress:line=>stderr(`${line}\n`),skipExisting:options.skipExisting,solarEpoch:await loadSolarEpoch(root)});
const good=results.filter(result=>!result.failed).map(result=>result.id),baked=(options.check||options.bake)&&good.length?await prepareObjects(good,{root,progress,...(options.bake?{}:{to:'page'})}):true;
diff --git a/packages/telescope-cli/src/new-object/drafts.mts b/packages/telescope-cli/src/new-object/drafts.mts
index 3bfe7de288e..6e08f7a1961 100644
--- a/packages/telescope-cli/src/new-object/drafts.mts
+++ b/packages/telescope-cli/src/new-object/drafts.mts
@@ -5,7 +5,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import type { Archive } from './archives/archives.mts';
-export interface Drafts { readonly stars: readonly unknown[]; readonly report: readonly string[] }
+export interface Drafts { readonly stars: readonly unknown[]; readonly pictures?: readonly unknown[]; readonly report: readonly string[] }
interface Context { readonly root: string; readonly progress: (line: string) => void; readonly archive: Archive }
export const DRAFT_ROUTES: Readonly Promise }>> = {
// Transiting planet hosts from the NASA Exoplanet Archive's default parameter sets (from-archive.mts).
@@ -38,6 +38,8 @@ export const DRAFT_ROUTES: Readonly (await import('./archives/m33-cepheids.mts')).draftsFromM33Cepheids(names, archive, root) },
// A star of another galaxy from any VizieR table that lists it with a position and a period (tables/table-stars.mts); `telescope stars GALAXY` finds the tables.
table: { names: 'CLASS:GALAXY=TABLE[#ROW]', draft: async (names, { archive, root }) => (await import('./archives/tables/table-stars.mts')).draftsFromTable(names, archive, root) },
+ // A picture ESA publishes for Hubble or Webb, as one more dataset of a page that shows a shaped layer bank (pictures/pictures.mts).
+ esa: { names: 'HOST=PAGE_URL', draft: async (names, context) => (await import('./pictures/pictures.mts')).draftsFromEsa(names, context) },
};
/** Draft `names` through `route` and write the spec file at `out`. */
@@ -45,7 +47,7 @@ export async function writeDrafts(route: string, names: readonly string[], out:
const source = DRAFT_ROUTES[route];
if (!source) throw new TypeError(`No draft route ${route}; the routes are ${Object.keys(DRAFT_ROUTES).map(key => `--from-${key}`).join(', ')}.`);
if (!names.length) throw new TypeError(`Usage: new-object --from-${route} ${source.names}... --out spec.json`);
- const { stars, report } = await source.draft(names, context), path = resolve(context.root, out);
- await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify({ stars }, null, 2)}\n`);
- return { path, entries: stars.length, report };
+ const { stars, pictures, report } = await source.draft(names, context), path = resolve(context.root, out);
+ await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(pictures ? { pictures } : { stars }, null, 2)}\n`);
+ return { path, entries: (pictures ?? stars).length, report };
}
diff --git a/packages/telescope-cli/src/new-object/new-object.test.mts b/packages/telescope-cli/src/new-object/new-object.test.mts
index eceee79d670..5d5c223337b 100644
--- a/packages/telescope-cli/src/new-object/new-object.test.mts
+++ b/packages/telescope-cli/src/new-object/new-object.test.mts
@@ -865,7 +865,7 @@ test('APOKASC-3 and Groenewegen (2013) rows draft single stars through the one r
const { physicalValues } = await import('./generate.mts'), values = physicalValues(measured, { sourceId: '1', ra: 0, dec: 0, g: 13, hasXpSampled: false });
assert.deepEqual([values.gm, values.logg], [0, 1.2]);
assert.match(values.massText, /No mass is measured, so GM is 0/u);
- assert.deepEqual(Object.keys(DRAFT_ROUTES), ['archive', 'debcat', 'apokasc', 'cepheids', 'k2', 'tess', 'gaia', 'hipparcos', 'iau', 'chara', 'npoi', 'sh0es', 'm31cepheids', 'm33cepheids', 'table']);
+ assert.deepEqual(Object.keys(DRAFT_ROUTES), ['archive', 'debcat', 'apokasc', 'cepheids', 'k2', 'tess', 'gaia', 'hipparcos', 'iau', 'chara', 'npoi', 'sh0es', 'm31cepheids', 'm33cepheids', 'table', 'esa']);
await assert.rejects(writeDrafts('gcvs', ['X'], 'output/x.json', { root, progress: () => {}, archive: {} as Archive }), /No draft route gcvs; the routes are --from-archive, --from-debcat, --from-apokasc, --from-cepheids, --from-k2, --from-tess, --from-gaia/u);
});
diff --git a/packages/telescope-cli/src/new-object/orbit.mts b/packages/telescope-cli/src/new-object/orbit.mts
index 9bdd099ea3f..1825c6976ff 100644
--- a/packages/telescope-cli/src/new-object/orbit.mts
+++ b/packages/telescope-cli/src/new-object/orbit.mts
@@ -20,7 +20,7 @@ export interface HostedOrbit {
}
const round = (value: number, digits: number) => Number(value.toFixed(digits));
/** The archive writes reference labels as HTML: accented author names arrive as entities. */
-const NAMED_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', agrave: 'à', egrave: 'è', ntilde: 'ñ', uuml: 'ü', ouml: 'ö', auml: 'ä', ccedil: 'ç', szlig: 'ß', oslash: 'ø', aring: 'å', Aacute: 'Á', Eacute: 'É', Oslash: 'Ø', ecirc: 'ê', ocirc: 'ô', acirc: 'â', scaron: 'š', zcaron: 'ž', ccaron: 'č' };
+const NAMED_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', agrave: 'à', egrave: 'è', ntilde: 'ñ', uuml: 'ü', ouml: 'ö', auml: 'ä', ccedil: 'ç', szlig: 'ß', oslash: 'ø', aring: 'å', Aacute: 'Á', Eacute: 'É', Oslash: 'Ø', ecirc: 'ê', ocirc: 'ô', acirc: 'â', scaron: 'š', zcaron: 'ž', ccaron: 'č', mu: 'µ', deg: '°' };
export const decodeEntities = (text: string) => text.replace(/&(#x[0-9a-f]+|#\d+|[a-zA-Z]+);/gu, (whole, code: string) =>
code.startsWith('#x') ? String.fromCodePoint(parseInt(code.slice(2), 16)) : code.startsWith('#') ? String.fromCodePoint(Number(code.slice(1))) : NAMED_ENTITIES[code] ?? whole);
const mod360 = (value: number) => ((value % 360) + 360) % 360;
diff --git a/packages/telescope-cli/src/new-object/pictures/esa-image.mts b/packages/telescope-cli/src/new-object/pictures/esa-image.mts
new file mode 100644
index 00000000000..c0669263930
--- /dev/null
+++ b/packages/telescope-cli/src/new-object/pictures/esa-image.mts
@@ -0,0 +1,64 @@
+/** A picture ESA publishes for Hubble or Webb (esahubble.org, esawebb.org): what its page says of it, and the sky tags
+ * its JPEG carries. The page gives the title, the credit, the release date and the colors; the file gives its own size,
+ * and in its embedded Astronomy Visualization Metadata the scale, the direction of north and one place on the sky. */
+import { decodeEntities } from '../orbit.mts';
+import type { Archive } from '../archives/archives.mts';
+
+/** A JPEG's sky tags, for the file as downloaded: degrees a pixel, north's turn counter-clockwise from up, and the sky
+ * place (right ascension, declination) of one pixel, counted from the picture's bottom left. */
+export interface SkyTags { readonly scaleDeg: number; readonly rotationDeg: number; readonly reference: readonly [number, number]; readonly referencePixel: readonly [number, number] }
+export interface PictureColor { readonly band: string; readonly shown: string; readonly line?: string; readonly wavelength: string; readonly instrument: string }
+export interface EsaPage { readonly id: string; readonly telescope: 'Hubble' | 'Webb'; readonly page: string; readonly download: string; readonly rights: string;
+ readonly title: string; readonly credit: string; readonly released: string; readonly original: readonly [number, number]; readonly colors: readonly PictureColor[] }
+
+const SITES: Readonly> = { 'esahubble.org': 'Hubble', 'esawebb.org': 'Webb' };
+const words = (html: string) => decodeEntities(html.replace(/<[^>]*>/gu, ' ')).replace(/\s+/gu, ' ').trim();
+
+/** The picture a page address names: `https://esawebb.org/images/weic2320c/`. */
+export function esaPictureAddress(address: string): Pick {
+ const found = /^https:\/\/(esahubble\.org|esawebb\.org)\/images\/([a-z0-9-]+)\/?$/u.exec(address);
+ if (!found) throw new TypeError(`${JSON.stringify(address)} is not a picture's page at esahubble.org or esawebb.org (https://esawebb.org/images//).`);
+ const [, site, id] = found as unknown as [string, string, string];
+ return { id, telescope: SITES[site]!, page: `https://${site}/images/${id}/`, download: `https://cdn.${site}/archives/images/large/${id}.jpg`, rights: `https://${site}/copyright/` };
+}
+
+/** What a picture's page says of it. A field the page lacks is refused by name: the generator writes none of them itself. */
+export function parseEsaPage(address: string, html: string): EsaPage {
+ const picture = esaPictureAddress(address), lacks = (what: string): never => { throw new TypeError(`${picture.page}: the page has no ${what} to read.`); };
+ const lines = html.replace(/Credit:\n
+| Release date: | 21 August 2023, 16:00 |
| Size: | 40 x 30 px |
+Colours & filters
| Band | Wavelength | Telescope |
${rows}