first commit, basic http server rending a sample svg is functioning
This commit is contained in:
commit
67ed949d8a
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
build
|
BIN
image-templates/Disaster-Girl.jpg
Normal file
BIN
image-templates/Disaster-Girl.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
47
package-lock.json
generated
Normal file
47
package-lock.json
generated
Normal 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
14
package.json
Normal 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
209
readme.md
Normal 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
19
src/config.ts
Normal 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
6
src/image-id.ts
Normal 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
53
src/render-svg.ts
Normal 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
9
src/start.ts
Normal 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
33
src/storage/interface.ts
Normal 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;
|
||||
}
|
80
src/storage/memory/images.ts
Normal file
80
src/storage/memory/images.ts
Normal file
File diff suppressed because one or more lines are too long
8
src/storage/memory/store.ts
Normal file
8
src/storage/memory/store.ts
Normal 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
24
src/storage/store.ts
Normal 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);
|
||||
}
|
85
src/web/request-handlers/image-request.ts
Normal file
85
src/web/request-handlers/image-request.ts
Normal 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><embed></h2>
|
||||
<embed type="image/svg+xml" src="http://me.local.jbrumond.me:54320/FrztglsLexLRWg"></embed>
|
||||
|
||||
<h2><object></h2>
|
||||
<object type="image/svg+xml" data="http://me.local.jbrumond.me:54320/FrztglsLexLRWg"></object>
|
||||
|
||||
<h2><iframe></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
12
src/web/server.ts
Normal 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
9
tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user