jpg 打印
This commit is contained in:
@@ -64,7 +64,7 @@ static const char *s_web_index =
|
||||
" </div>\n"
|
||||
"\n"
|
||||
" <label>Image Upload</label>\n"
|
||||
" <input id='imageFile' type='file' accept='image/*'>\n"
|
||||
" <input id='imageFile' type='file' accept='.jpg,.jpeg,image/jpeg'>\n"
|
||||
" <div class='row'>\n"
|
||||
" <input id='imgThreshold' type='number' min='0' max='255' value='160' placeholder='Threshold 0-255' style='max-width:160px'>\n"
|
||||
" <input id='imgMaxHeight' type='number' min='64' max='3000' value='2200' placeholder='Max height' style='max-width:160px'>\n"
|
||||
@@ -86,7 +86,7 @@ static const char *s_web_index =
|
||||
" </label>\n"
|
||||
" <button id='btnPrintImage'>Print Image</button>\n"
|
||||
" </div>\n"
|
||||
" <div class='small'>Image bytes are uploaded as gray8; ESP32-S3 handles threshold/scale/raster processing.</div>\n"
|
||||
" <div class='small'>Direct JPG upload: browser sends binary JPEG; ESP32-S3 decodes/scales/thresholds then prints.</div>\n"
|
||||
"\n"
|
||||
" <div class='small'>Tip: connect printer first, then submit print jobs.</div>\n"
|
||||
" <pre id='out'>Ready.</pre>\n"
|
||||
@@ -128,92 +128,41 @@ static const char *s_web_index =
|
||||
" return n;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function uint8ToBase64(data) {\n"
|
||||
" let out = '';\n"
|
||||
" const chunkSize = 0x8000;\n"
|
||||
" for (let i = 0; i < data.length; i += chunkSize) {\n"
|
||||
" const chunk = data.subarray(i, i + chunkSize);\n"
|
||||
" out += String.fromCharCode.apply(null, chunk);\n"
|
||||
" }\n"
|
||||
" return btoa(out);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function loadImage(file) {\n"
|
||||
" if (window.createImageBitmap) {\n"
|
||||
" return window.createImageBitmap(file);\n"
|
||||
" }\n"
|
||||
" return new Promise((resolve, reject) => {\n"
|
||||
" const url = URL.createObjectURL(file);\n"
|
||||
" const img = new Image();\n"
|
||||
" img.onload = () => {\n"
|
||||
" URL.revokeObjectURL(url);\n"
|
||||
" resolve(img);\n"
|
||||
" };\n"
|
||||
" img.onerror = () => {\n"
|
||||
" URL.revokeObjectURL(url);\n"
|
||||
" reject(new Error('image decode failed'));\n"
|
||||
" };\n"
|
||||
" img.src = url;\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function buildImagePayload(file) {\n"
|
||||
" function buildImageUploadRequest(file) {\n"
|
||||
" if (!file) {\n"
|
||||
" throw new Error('please select an image first');\n"
|
||||
" throw new Error('please select a jpg image first');\n"
|
||||
" }\n"
|
||||
" const name = (file.name || '').toLowerCase();\n"
|
||||
" const type = (file.type || '').toLowerCase();\n"
|
||||
" const isJpeg = type.includes('jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg');\n"
|
||||
" if (!isJpeg) {\n"
|
||||
" throw new Error('only .jpg/.jpeg is supported in direct upload mode');\n"
|
||||
" }\n"
|
||||
" if (file.size <= 0) {\n"
|
||||
" throw new Error('empty image file');\n"
|
||||
" }\n"
|
||||
" if (file.size > 3 * 1024 * 1024) {\n"
|
||||
" throw new Error('jpg too large (>3MB), backend limit is 4MB');\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" const src = await loadImage(file);\n"
|
||||
" const srcW = src.width || src.naturalWidth || 0;\n"
|
||||
" const srcH = src.height || src.naturalHeight || 0;\n"
|
||||
" if (srcW <= 0 || srcH <= 0) {\n"
|
||||
" throw new Error('invalid image size');\n"
|
||||
" }\n"
|
||||
" const densityMap = {\n"
|
||||
" '较淡': 'light',\n"
|
||||
" '中等': 'medium',\n"
|
||||
" '较浓': 'dark',\n"
|
||||
" '最深': 'max'\n"
|
||||
" };\n"
|
||||
"\n"
|
||||
" // Keep request body under 1MB limit in read_body().\n"
|
||||
" const maxRawBytes = 680000;\n"
|
||||
" let outW = srcW;\n"
|
||||
" let outH = srcH;\n"
|
||||
" const pixels = srcW * srcH;\n"
|
||||
" if (pixels > maxRawBytes) {\n"
|
||||
" const scale = Math.sqrt(maxRawBytes / pixels);\n"
|
||||
" outW = Math.max(1, Math.floor(srcW * scale));\n"
|
||||
" outH = Math.max(1, Math.floor(srcH * scale));\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" const canvas = document.createElement('canvas');\n"
|
||||
" canvas.width = outW;\n"
|
||||
" canvas.height = outH;\n"
|
||||
" const ctx = canvas.getContext('2d', { willReadFrequently: true });\n"
|
||||
" ctx.drawImage(src, 0, 0, outW, outH);\n"
|
||||
" if (typeof src.close === 'function') {\n"
|
||||
" src.close();\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" const rgba = ctx.getImageData(0, 0, outW, outH).data;\n"
|
||||
" const gray = new Uint8Array(outW * outH);\n"
|
||||
" for (let i = 0, j = 0; i < rgba.length; i += 4, ++j) {\n"
|
||||
" const r = rgba[i];\n"
|
||||
" const g = rgba[i + 1];\n"
|
||||
" const b = rgba[i + 2];\n"
|
||||
" gray[j] = (77 * r + 150 * g + 29 * b) >> 8;\n"
|
||||
" }\n"
|
||||
" const q = new URLSearchParams();\n"
|
||||
" q.set('scale_to_width', imgScaleToWidthEl.checked ? '1' : '0');\n"
|
||||
" q.set('threshold', String(toInt(imgThresholdEl.value, 160, 0, 255)));\n"
|
||||
" q.set('invert', imgInvertEl.checked ? '1' : '0');\n"
|
||||
" q.set('max_height', String(toInt(imgMaxHeightEl.value, 2200, 64, 3000)));\n"
|
||||
" q.set('density', densityMap[imgDensityEl.value] || 'medium');\n"
|
||||
"\n"
|
||||
" return {\n"
|
||||
" payload: {\n"
|
||||
" width: outW,\n"
|
||||
" height: outH,\n"
|
||||
" encoding: 'base64_gray8',\n"
|
||||
" data: uint8ToBase64(gray),\n"
|
||||
" scale_to_width: !!imgScaleToWidthEl.checked,\n"
|
||||
" threshold: toInt(imgThresholdEl.value, 160, 0, 255),\n"
|
||||
" invert: !!imgInvertEl.checked,\n"
|
||||
" max_height: toInt(imgMaxHeightEl.value, 2200, 64, 3000),\n"
|
||||
" density: imgDensityEl.value || '中等'\n"
|
||||
" },\n"
|
||||
" srcW,\n"
|
||||
" srcH,\n"
|
||||
" outW,\n"
|
||||
" outH\n"
|
||||
" path: '/v1/print/image?' + q.toString(),\n"
|
||||
" body: file,\n"
|
||||
" contentType: file.type || 'image/jpeg'\n"
|
||||
" };\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
@@ -230,6 +179,19 @@ static const char *s_web_index =
|
||||
" return data;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function callBinaryApi(path, body, contentType) {\n"
|
||||
" const h = headers(false);\n"
|
||||
" h['Content-Type'] = contentType || 'application/octet-stream';\n"
|
||||
" const r = await fetch(path, { method: 'POST', headers: h, body });\n"
|
||||
" const text = await r.text();\n"
|
||||
" let data = text;\n"
|
||||
" try { data = JSON.parse(text); } catch (_) {}\n"
|
||||
" if (!r.ok) {\n"
|
||||
" throw { status: r.status, data };\n"
|
||||
" }\n"
|
||||
" return data;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function run(fn) {\n"
|
||||
" try {\n"
|
||||
" const data = await fn();\n"
|
||||
@@ -265,12 +227,9 @@ static const char *s_web_index =
|
||||
"\n"
|
||||
" document.getElementById('btnPrintImage').onclick = () => run(async () => {\n"
|
||||
" const file = imageFileEl.files && imageFileEl.files[0] ? imageFileEl.files[0] : null;\n"
|
||||
" const converted = await buildImagePayload(file);\n"
|
||||
" const result = await callApi('/v1/print/image', 'POST', converted.payload);\n"
|
||||
" if (converted.srcW !== converted.outW || converted.srcH !== converted.outH) {\n"
|
||||
" result.client_note = 'image resized before upload: ' +\n"
|
||||
" converted.srcW + 'x' + converted.srcH + ' -> ' + converted.outW + 'x' + converted.outH;\n"
|
||||
" }\n"
|
||||
" const req = buildImageUploadRequest(file);\n"
|
||||
" const result = await callBinaryApi(req.path, req.body, req.contentType);\n"
|
||||
" result.client_note = 'uploaded jpg bytes: ' + file.size;\n"
|
||||
" return result;\n"
|
||||
" });\n"
|
||||
" </script>\n"
|
||||
|
||||
@@ -6,7 +6,26 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "mbedtls/base64.h"
|
||||
|
||||
#define REST_SERVER_MAX_BODY_BYTES (4 * 1024 * 1024)
|
||||
|
||||
static void *alloc_prefer_psram(size_t size) {
|
||||
void *ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (ptr == NULL) {
|
||||
ptr = malloc(size);
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
|
||||
static void *calloc_prefer_psram(size_t n, size_t size) {
|
||||
void *ptr = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (ptr == NULL) {
|
||||
ptr = calloc(n, size);
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
bool rest_server_auth_ok(httpd_req_t *req) {
|
||||
if (strlen(CONFIG_LYF_API_KEY) == 0) {
|
||||
return true;
|
||||
@@ -49,11 +68,11 @@ esp_err_t rest_server_read_body(httpd_req_t *req, char **out_body) {
|
||||
|
||||
*out_body = NULL;
|
||||
|
||||
if (req->content_len <= 0 || req->content_len > (1024 * 1024)) {
|
||||
if (req->content_len <= 0 || req->content_len > REST_SERVER_MAX_BODY_BYTES) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
char *buf = (char *)calloc(1, req->content_len + 1);
|
||||
char *buf = (char *)calloc_prefer_psram(1, (size_t)req->content_len + 1);
|
||||
if (buf == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
@@ -111,7 +130,7 @@ esp_err_t rest_server_base64_decode_alloc(const char *b64, uint8_t **out_raw, si
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
uint8_t *buf = (uint8_t *)malloc(decoded_len);
|
||||
uint8_t *buf = (uint8_t *)alloc_prefer_psram(decoded_len);
|
||||
if (buf == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,117 @@ static esp_err_t submit_raster_job_and_reply(httpd_req_t *req,
|
||||
return err;
|
||||
}
|
||||
|
||||
static bool parse_bool_text(const char *text, bool *out_value) {
|
||||
if (text == NULL || out_value == NULL) {
|
||||
return false;
|
||||
}
|
||||
if (strcmp(text, "1") == 0 || strcasecmp(text, "true") == 0 || strcasecmp(text, "yes") == 0) {
|
||||
*out_value = true;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(text, "0") == 0 || strcasecmp(text, "false") == 0 || strcasecmp(text, "no") == 0) {
|
||||
*out_value = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static const char *map_density_text(const char *text) {
|
||||
if (text == NULL || text[0] == '\0') {
|
||||
return "中等";
|
||||
}
|
||||
if (strcasecmp(text, "light") == 0 || strcmp(text, "较淡") == 0) {
|
||||
return "较淡";
|
||||
}
|
||||
if (strcasecmp(text, "medium") == 0 || strcmp(text, "中等") == 0) {
|
||||
return "中等";
|
||||
}
|
||||
if (strcasecmp(text, "dark") == 0 || strcmp(text, "较浓") == 0) {
|
||||
return "较浓";
|
||||
}
|
||||
if (strcasecmp(text, "max") == 0 || strcmp(text, "最深") == 0) {
|
||||
return "最深";
|
||||
}
|
||||
return "中等";
|
||||
}
|
||||
|
||||
static void parse_image_upload_options(httpd_req_t *req,
|
||||
bool *out_scale_to_width,
|
||||
uint8_t *out_threshold,
|
||||
bool *out_invert,
|
||||
uint16_t *out_max_height,
|
||||
const char **out_density) {
|
||||
if (out_scale_to_width != NULL) {
|
||||
*out_scale_to_width = true;
|
||||
}
|
||||
if (out_threshold != NULL) {
|
||||
*out_threshold = 160;
|
||||
}
|
||||
if (out_invert != NULL) {
|
||||
*out_invert = false;
|
||||
}
|
||||
if (out_max_height != NULL) {
|
||||
*out_max_height = 2200;
|
||||
}
|
||||
if (out_density != NULL) {
|
||||
*out_density = "中等";
|
||||
}
|
||||
|
||||
if (req == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
int qlen = httpd_req_get_url_query_len(req);
|
||||
if (qlen <= 0 || qlen > 512) {
|
||||
return;
|
||||
}
|
||||
|
||||
char *query = (char *)calloc(1, (size_t)qlen + 1);
|
||||
if (query == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (httpd_req_get_url_query_str(req, query, (size_t)qlen + 1) != ESP_OK) {
|
||||
free(query);
|
||||
return;
|
||||
}
|
||||
|
||||
char value[48] = {0};
|
||||
if (httpd_query_key_value(query, "scale_to_width", value, sizeof(value)) == ESP_OK && out_scale_to_width != NULL) {
|
||||
bool parsed = *out_scale_to_width;
|
||||
if (parse_bool_text(value, &parsed)) {
|
||||
*out_scale_to_width = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "invert", value, sizeof(value)) == ESP_OK && out_invert != NULL) {
|
||||
bool parsed = *out_invert;
|
||||
if (parse_bool_text(value, &parsed)) {
|
||||
*out_invert = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "threshold", value, sizeof(value)) == ESP_OK && out_threshold != NULL) {
|
||||
long v = strtol(value, NULL, 10);
|
||||
if (v >= 0 && v <= 255) {
|
||||
*out_threshold = (uint8_t)v;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "max_height", value, sizeof(value)) == ESP_OK && out_max_height != NULL) {
|
||||
long v = strtol(value, NULL, 10);
|
||||
if (v >= 64 && v <= 3000) {
|
||||
*out_max_height = (uint16_t)v;
|
||||
}
|
||||
}
|
||||
|
||||
if (httpd_query_key_value(query, "density", value, sizeof(value)) == ESP_OK && out_density != NULL) {
|
||||
*out_density = map_density_text(value);
|
||||
}
|
||||
|
||||
free(query);
|
||||
}
|
||||
|
||||
static esp_err_t decode_image_json_to_raster(cJSON *json,
|
||||
uint16_t *out_width,
|
||||
uint16_t *out_height,
|
||||
@@ -235,40 +346,53 @@ esp_err_t rest_server_print_image_post(httpd_req_t *req) {
|
||||
return rest_server_send_error(req, "401 Unauthorized", "unauthorized");
|
||||
}
|
||||
|
||||
char content_type[96] = {0};
|
||||
if (httpd_req_get_hdr_value_str(req, "Content-Type", content_type, sizeof(content_type)) == ESP_OK) {
|
||||
if (strstr(content_type, "application/json") != NULL) {
|
||||
return rest_server_send_error(req, "400 Bad Request", "use binary jpeg body, not json");
|
||||
}
|
||||
}
|
||||
|
||||
bool scale_to_width = true;
|
||||
uint8_t threshold = 160;
|
||||
bool invert = false;
|
||||
uint16_t max_height = 2200;
|
||||
const char *density = "中等";
|
||||
parse_image_upload_options(req,
|
||||
&scale_to_width,
|
||||
&threshold,
|
||||
&invert,
|
||||
&max_height,
|
||||
&density);
|
||||
|
||||
char *body = NULL;
|
||||
esp_err_t body_err = rest_server_read_body(req, &body);
|
||||
if (body_err != ESP_OK) {
|
||||
return rest_server_send_error(req, "400 Bad Request", "invalid request body");
|
||||
}
|
||||
|
||||
cJSON *json = cJSON_Parse(body);
|
||||
free(body);
|
||||
if (json == NULL) {
|
||||
return rest_server_send_error(req, "400 Bad Request", "invalid json");
|
||||
}
|
||||
|
||||
cJSON *jdensity = cJSON_GetObjectItemCaseSensitive(json, "density");
|
||||
const char *density = (cJSON_IsString(jdensity) && jdensity->valuestring != NULL)
|
||||
? jdensity->valuestring
|
||||
: "中等";
|
||||
|
||||
uint16_t width = 0;
|
||||
uint16_t height = 0;
|
||||
uint8_t *raster = NULL;
|
||||
size_t raster_len = 0;
|
||||
char decode_err[128] = {0};
|
||||
esp_err_t decode_rc = decode_image_json_to_raster(json,
|
||||
&width,
|
||||
&height,
|
||||
&raster,
|
||||
&raster_len,
|
||||
decode_err,
|
||||
sizeof(decode_err));
|
||||
cJSON_Delete(json);
|
||||
esp_err_t decode_rc = raster_tools_convert_jpeg_to_raster_384((const uint8_t *)body,
|
||||
(size_t)req->content_len,
|
||||
scale_to_width,
|
||||
threshold,
|
||||
invert,
|
||||
max_height,
|
||||
&width,
|
||||
&height,
|
||||
&raster,
|
||||
&raster_len,
|
||||
decode_err,
|
||||
sizeof(decode_err));
|
||||
free(body);
|
||||
if (decode_rc != ESP_OK) {
|
||||
return rest_server_send_error(req,
|
||||
"400 Bad Request",
|
||||
decode_err[0] != '\0' ? decode_err : "decode image failed");
|
||||
decode_err[0] != '\0' ? decode_err : "decode jpeg failed");
|
||||
}
|
||||
|
||||
esp_err_t submit_rc = submit_raster_job_and_reply(req,
|
||||
|
||||
Reference in New Issue
Block a user