first commit, basic http server rending a sample svg is functioning

This commit is contained in:
James Brumond 2023-07-02 23:46:40 -07:00
commit 67ed949d8a
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
17 changed files with 619 additions and 0 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
indent_style = tab
indent_size = 4
[*.{md,yaml,yml,json}]
indent_style = space
indent_size = 2

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
build

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

47
package-lock.json generated Normal file
View File

@ -0,0 +1,47 @@
{
"name": "mini-macro-service",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "mini-macro-service",
"devDependencies": {
"@types/node": "^20.3.3",
"typescript": "^5.1.6"
}
},
"node_modules/@types/node": {
"version": "20.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz",
"integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==",
"dev": true
},
"node_modules/typescript": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
"integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
},
"dependencies": {
"@types/node": {
"version": "20.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz",
"integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==",
"dev": true
},
"typescript": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
"integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
"dev": true
}
}
}

14
package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "mini-macro-service",
"private": true,
"scripts": {
"tsc": "tsc --build"
},
"engines": {
"node": ">=17"
},
"devDependencies": {
"@types/node": "^20.3.3",
"typescript": "^5.1.6"
}
}

209
readme.md Normal file
View File

@ -0,0 +1,209 @@
# Minimal Image Macro Server (mini-macro)
Self-hostable image macro creation and hosting service.
- Add and use whatever template images you want
- Add as many pieces of text, formatted as you want, to each image
- Images are stored and transferred efficiently as SVGs
- SVG markup is screen-reader accessible
- Web UI and [REST API](#rest-api) for managing image macros and templates
## Efficient, Accessible SVG Image Macros
Rather than render new PNG (or other raster) formatted images, the renderer used here actually generates SVG files that look something like this:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1"
width="800" height="600"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
style="user-select: none">
<title>Image Macro Title Text</title>
<!-- The image template is referenced as a separate, external file -->
<image x="0" y="0"
width="800" height="600"
xlink:href="http://example.com/abcdefghijkl">
<title>Image Template Title Text</title>
</image>
<!-- Each section of text gets its own <text> node -->
<text x="400" y="50"
text-anchor="middle"
font-size="48"
font-family="sans-serif"
font-weight="600"
stroke="#000"
stroke-width="2"
fill="#fff"
>Top Text</text>
<text x="400" y="550"
text-anchor="middle"
font-size="48"
font-family="sans-serif"
font-weight="600"
stroke="#000"
stroke-width="2"
fill="#fff"
>Bottom Text</text>
</svg>
```
That sample XML above is about 800 bytes in total with the comments removed; The raster formats that might otherwise be used would typically be several kilobytes _at least_.
Obviously, the externally referenced template image still has to be downloaded. However that external image (the largest part of an image macro, and the part that gets reused over and over) can be cached separately by clients so that it does not need to be downloaded separately each time it gets used to make a new image. Additionally, only one copy of it actually gets stored on the server, regardless of how many times its used.
Additionally, since an SVG is markup rather than a raster image, they can be read by screen readers (including the additional `<title>` text for both the whole image, and the template).
## Embedding in HTML
The one drawback to this approach is that, in web browsers, [SVGs do not load external resources when rendered in an image context](https://developer.mozilla.org/en-US/docs/Web/SVG/SVG_as_an_Image) (e.g. when used in an `<img>` tag). That said, you can embed it using either the `<embed>` or `<object>` elements, which will both render it as a document.
```html
<!-- NOT This: <img> doesn't work with external resources -->
<img type="image/svg+xml" src="http://example.com/FrztglsLexLRWg">
<!-- Either of these will work: -->
<embed type="image/svg+xml" src="http://example.com/FrztglsLexLRWg"></embed>
<object type="image/svg+xml" data="http://example.com/FrztglsLexLRWg"></object>
```
## REST API
### Image Template List
#### Media Type: `application/vnd.mini-macro.image-templates+json`
_todo_
Supports: `GET`
```json
{
"items": [
{
"id": "<template_id>",
"title": "Image Template Title",
"width": 800,
"height": 600,
"links": {
"self": {
"href": "/templates/<template_id>",
"type": "application/vnd.mini-macro.image-template+json"
},
"alternate": {
"href": "/templates/<template_id>",
"type": "image/png"
}
}
}
],
"links": {
"next": {
"href": "/templates?anchor=<template_id>",
"type": "application/vnd.mini-macro.image-templates+json"
}
}
}
```
### Image Template
General Support: `DELETE`
#### Media Type: `application/vnd.mini-macro.image-template+json`
_todo_
Supports: `GET`, `POST`, `PUT`
```json
{
"id": "<template_id>",
"title": "Image Template Title",
"width": 800,
"height": 600,
"links": {
"self": {
"href": "/templates/<template_id>",
"type": "application/vnd.mini-macro.image-template+json"
},
"alternate": {
"href": "/templates/<template_id>",
"type": "image/png"
}
}
}
```
#### Other Media Types: `image/png`, `image/jpeg`
Supports: `GET`, `PUT`
### Image Macro
General Support: `DELETE`
#### Media Type: `application/vnd.mini-macro.image-macro+json`
_todo_
Supports: `GET`, `POST`, `PUT`
```json
{
"id": "<image_id>",
"name": "Image Macro Name",
"text_style": {
"fill": "#fff",
"stroke": "#000",
"stroke_width": 2,
"font_size": 48,
"font_family": "sans-serif",
"font_weight": 600
},
"text": [
{
"text": "Top Text",
"top": 50,
"left": 400,
},
{
"text": "Bottom Text",
"top": 550,
"left": 400,
},
]
"links": {
"self": {
"href": "/images/<template_id>",
"type": "application/vnd.mini-macro.image-macro+json"
},
"alternate": {
"href": "/images/<template_id>",
"type": "image/png"
},
"/rel/template": {
"href": "/templates/<template_id>",
"type": "application/vnd.mini-macro.image-template+json"
}
}
}
```
#### Other Media Types: `image/svg+xml`
Supports: `GET`

19
src/config.ts Normal file
View File

@ -0,0 +1,19 @@
export namespace http {
export namespace images {
export const port = 54320;
export const address = '0.0.0.0';
export const public_url = 'http://me.local.jbrumond.me:54320';
}
export namespace api {
export const port = 54321;
export const address = '0.0.0.0';
export const public_url = 'http://me.local.jbrumond.me:54321';
}
}
export namespace storage {
export type Mode = 'memory' | 'file' | 'sqlite';
export const mode: Mode = 'memory';
}

6
src/image-id.ts Normal file
View File

@ -0,0 +1,6 @@
import { pseudoRandomBytes } from 'crypto';
export function generate_image_id() {
return pseudoRandomBytes(10).toString('base64url');
}

53
src/render-svg.ts Normal file
View File

@ -0,0 +1,53 @@
export interface ImageParams {
title: string;
image: {
url: string;
title: string;
width: number;
height: number;
};
text: TextParams[];
text_style: {
font_size: number;
font_family: string;
font_weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
fill: string;
stroke: string;
stroke_width: number;
}
}
export interface TextParams {
text: string;
top: number;
left: number;
}
export const render_svg = (params: ImageParams) => `
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1"
width="${params.image.width}" height="${params.image.height}"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
style="user-select: none">
<title>${params.title}</title>
<image x="0" y="0"
width="${params.image.width}" height="${params.image.height}"
href="${params.image.url}"
xlink:href="${params.image.url}">
<title>${params.image.title}</title>
</image>
${params.text.map((text) => `
<text x="${text.left}" y="${text.top}"
text-anchor="middle"
font-size="${params.text_style.font_size}"
font-family="${params.text_style.font_family}"
font-weight="${params.text_style.font_weight}"
stroke="${params.text_style.stroke}"
stroke-width="${params.text_style.stroke_width}"
fill="${params.text_style.fill}"
>${text.text}</text>`
).join('')}
</svg>`.trimStart();

9
src/start.ts Normal file
View File

@ -0,0 +1,9 @@
import { init_http_servers } from './web/server';
// import { } from './api/server';
main();
async function main() {
init_http_servers();
}

33
src/storage/interface.ts Normal file
View File

@ -0,0 +1,33 @@
import { TextParams } from '../render-svg';
export interface Store {
get_image_by_id(image_id: string) : Promise<ImageTemplate | ImageMacro>;
get_image_data_by_id(image_id: string) : Promise<ImageTemplate | ImageMacroData>;
}
export type ImageMacroMediaType = 'image/svg+xml';
export type ImageTemplateMediaType = 'image/jpeg' | 'image/png';
export interface ImageTemplate {
id: string;
title: string;
width: number;
height: number;
media_type: ImageTemplateMediaType;
content: Buffer;
}
export interface ImageMacro {
id: string;
media_type: ImageMacroMediaType;
content: string;
}
export interface ImageMacroData {
id: string;
title: string;
template_id: string;
text_nodes: TextParams[];
content: string;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
import { Store } from '../interface';
import { get_image_by_id, get_image_data_by_id } from './images';
export const memory_store: Store = {
get_image_by_id,
get_image_data_by_id,
}

24
src/storage/store.ts Normal file
View File

@ -0,0 +1,24 @@
import * as conf from '../config';
import { Store } from './interface';
import { memory_store } from './memory/store';
export let store: Store;
switch (conf.storage.mode) {
case 'memory':
store = memory_store;
break;
case 'file':
//
// break;
case 'sqlite':
//
// break;
default:
console.error('Unknown storage mode configured');
process.exit(1);
}

View File

@ -0,0 +1,85 @@
import { ImageMacro, ImageTemplate } from '../../storage/interface';
import { store } from '../../storage/store';
import { IncomingMessage, ServerResponse } from 'http';
export async function handle_image_request(req: IncomingMessage, res: ServerResponse) {
if (req.url === '/' && req.method === 'GET') {
res.writeHead(200, {
'content-type': 'text/html',
});
res.end(`<!doctype html>
<html>
<head></head>
<body>
<h2>&lt;embed&gt;</h2>
<embed type="image/svg+xml" src="http://me.local.jbrumond.me:54320/FrztglsLexLRWg"></embed>
<h2>&lt;object&gt;</h2>
<object type="image/svg+xml" data="http://me.local.jbrumond.me:54320/FrztglsLexLRWg"></object>
<h2>&lt;iframe&gt;</h2>
<iframe src="http://me.local.jbrumond.me:54320/FrztglsLexLRWg"></iframe>
</body>
</html>
`);
return;
}
if (! req.url.startsWith('/')) {
return send_404_not_found(res);
}
switch (req.method) {
case 'OPTIONS': return send_options_response(res);
case 'GET': return send_image_response(res, await get_image(req));
case 'HEAD': return send_image_response(res, await get_image(req), false);
}
return send_415_method_not_allowed(res);
}
function send_options_response(res: ServerResponse) {
res.writeHead(200, {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, HEAD, OPTIONS',
});
res.end();
}
function send_image_response(res: ServerResponse, image: ImageTemplate | ImageMacro, send_content = true) {
if (! image) {
return send_404_not_found(res);
}
const buf = image.media_type === 'image/svg+xml'
? Buffer.from(image.content, 'utf8')
: image.content;
res.writeHead(200, {
'content-type': image.media_type,
'content-length': buf.byteLength,
'cache-control': 'public, max-age=31536000',
});
res.end(send_content ? buf : void 0);
}
function send_404_not_found(res: ServerResponse) {
res.writeHead(404, {
'content-type': 'text/plain'
});
res.end('Image not found');
}
function send_415_method_not_allowed(res: ServerResponse) {
res.writeHead(415, {
'content-type': 'text/plain'
});
res.end('Method not allowed');
}
function get_image(req: IncomingMessage) {
const image_id = req.url.slice(1);
return store.get_image_by_id(image_id);
}

12
src/web/server.ts Normal file
View File

@ -0,0 +1,12 @@
import * as conf from '../config';
import { createServer } from 'http';
import { handle_image_request } from './request-handlers/image-request';
export function init_http_servers() {
const image_server = createServer(handle_image_request);
image_server.listen(conf.http.images.port, conf.http.images.address, () => {
console.log('HTTP image server listening at %s:%d', conf.http.images.address, conf.http.images.port);
});
}

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"outDir": "./build",
"rootDir": "./src"
},
"include": [
"./src/**/*.ts"
]
}