feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表

This commit is contained in:
2026-08-13 14:22:48 +08:00
parent 1d17b5e20d
commit e518540235
32 changed files with 3525 additions and 592 deletions
@@ -22,9 +22,9 @@
#include <future>
#include <atomic>
#include <random>
#include <functional>
#include <numeric>
#include <limits>
#include <functional>
#include <numeric>
#include <limits>
// ==========================================
// Thread Pool (avoid per-query thread creation)
@@ -115,13 +115,13 @@ public:
int w;
};
// Original mask-free coordinates. Sorting is lazy because the active
// query_direct/query_near_center paths do not need the O(A log A) order.
std::vector<std::pair<int, int>> valid_coords;
bool valid_coords_center_sorted = false;
double free_center_y = 0.0;
double free_center_x = 0.0;
int spiral_cursor = 1;
// Original mask-free coordinates. Sorting is lazy because the active
// query_direct/query_near_center paths do not need the O(A log A) order.
std::vector<std::pair<int, int>> valid_coords;
bool valid_coords_center_sorted = false;
double free_center_y = 0.0;
double free_center_x = 0.0;
int spiral_cursor = 1;
// Lazy update buffers
std::vector<int32_t> diff;
@@ -139,9 +139,9 @@ public:
}
void init_from_buffer(unsigned char* raw_mask, int h, int w) {
valid_coords.reserve(h * w / 2);
uint64_t free_y_sum = 0;
uint64_t free_x_sum = 0;
valid_coords.reserve(h * w / 2);
uint64_t free_y_sum = 0;
uint64_t free_x_sum = 0;
// Initialize canvas from mask
ensure_canvas();
@@ -157,48 +157,48 @@ public:
if (is_blocked) {
canvas[i * width + j] = 1;
}
if (!is_blocked) {
valid_coords.push_back({i, j});
free_y_sum += (uint64_t)i;
free_x_sum += (uint64_t)j;
}
}
}
if (!valid_coords.empty()) {
free_center_y = (double)free_y_sum / (double)valid_coords.size();
free_center_x = (double)free_x_sum / (double)valid_coords.size();
} else {
free_center_y = (double)h * 0.5;
free_center_x = (double)w * 0.5;
}
valid_coords_center_sorted = false;
spiral_cursor = 1;
if (!is_blocked) {
valid_coords.push_back({i, j});
free_y_sum += (uint64_t)i;
free_x_sum += (uint64_t)j;
}
}
}
if (!valid_coords.empty()) {
free_center_y = (double)free_y_sum / (double)valid_coords.size();
free_center_x = (double)free_x_sum / (double)valid_coords.size();
} else {
free_center_y = (double)h * 0.5;
free_center_x = (double)w * 0.5;
}
valid_coords_center_sorted = false;
spiral_cursor = 1;
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
}
void ensure_valid_coords_center_sorted() {
if (valid_coords_center_sorted) return;
const double center_y = free_center_y;
const double center_x = free_center_x;
std::sort(valid_coords.begin(), valid_coords.end(),
[center_y, center_x](const std::pair<int, int>& a, const std::pair<int, int>& b) {
double ay = (double)a.first - center_y;
double ax = (double)a.second - center_x;
double by = (double)b.first - center_y;
double bx = (double)b.second - center_x;
return ay * ay + ax * ax < by * by + bx * bx;
}
);
valid_coords_center_sorted = true;
}
// Legacy coordinate ordering remains available to compatibility callers.
void reorder_stratified(int bands) {
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
const size_t len = valid_coords.size();
dirty_count = 0;
}
void ensure_valid_coords_center_sorted() {
if (valid_coords_center_sorted) return;
const double center_y = free_center_y;
const double center_x = free_center_x;
std::sort(valid_coords.begin(), valid_coords.end(),
[center_y, center_x](const std::pair<int, int>& a, const std::pair<int, int>& b) {
double ay = (double)a.first - center_y;
double ax = (double)a.second - center_x;
double by = (double)b.first - center_y;
double bx = (double)b.second - center_x;
return ay * ay + ax * ax < by * by + bx * bx;
}
);
valid_coords_center_sorted = true;
}
// Legacy coordinate ordering remains available to compatibility callers.
void reorder_stratified(int bands) {
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
const size_t len = valid_coords.size();
if (bands <= 1 || len == 0) return;
if ((size_t)bands > len) bands = (int)len;
@@ -305,7 +305,7 @@ public:
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
for (int i = 0; i < height; ++i) {
for (int i = 0; i < height; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < width; ++j) {
uint32_t val = (raw_pixels[i * width + j] > 0) ? 1 : 0;
@@ -343,10 +343,10 @@ public:
}
// Rebuild integral from the internal canvas (partial from pos_r, pos_c)
void rebuild_from_canvas(int pos_r, int pos_c) {
if (canvas.empty()) return;
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
}
void rebuild_from_canvas(int pos_r, int pos_c) {
if (canvas.empty()) return;
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
}
// v4: Partial integral rebuild from position (pos_r, pos_c) downward
// Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region
@@ -450,9 +450,9 @@ public:
return {-1, -1};
}
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Apply pending rectangle updates before reading the integral image.
// Exact-glyph placement uses its own canvas-only path.
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Apply pending rectangle updates before reading the integral image.
// Exact-glyph placement uses its own canvas-only path.
flush();
int max_row = height - box_h;
@@ -473,12 +473,12 @@ public:
return {true, y, x};
}
// Random probes avoid an O(H*W) scan during early and middle packing.
// Increase the budget as occupancy rises.
{
const double occ_ratio = (double)total_occupied / (double)(width * height);
const int probe_budget = (occ_ratio < 0.15) ? 32 : (occ_ratio < 0.40) ? 96 : 192;
const int n_probes = (int)std::min<int64_t>(probe_budget, total_positions);
// Random probes avoid an O(H*W) scan during early and middle packing.
// Increase the budget as occupancy rises.
{
const double occ_ratio = (double)total_occupied / (double)(width * height);
const int probe_budget = (occ_ratio < 0.15) ? 32 : (occ_ratio < 0.40) ? 96 : 192;
const int n_probes = (int)std::min<int64_t>(probe_budget, total_positions);
for (int p = 0; p < n_probes; ++p) {
int y = std::uniform_int_distribution<int>(0, max_row)(rng);
int x = std::uniform_int_distribution<int>(0, max_col)(rng);
@@ -496,28 +496,28 @@ public:
}
if (nt <= 1) {
// Single-pass reservoir halves the work of count-then-pick. A
// small LCG avoids invoking mt19937 for every valid position.
uint64_t count = 0;
int best_y = -1, best_x = -1;
uint32_t state = seed ? seed : 1u;
// Single-pass reservoir halves the work of count-then-pick. A
// small LCG avoids invoking mt19937 for every valid position.
uint64_t count = 0;
int best_y = -1, best_x = -1;
uint32_t state = seed ? seed : 1u;
for (int i = 0; i <= max_row; ++i) {
for (int j = 0; j <= max_col; ++j) {
if (get_area_sum_fast(i, j, box_h, box_w) == 0) {
++count;
// Replace the current result with probability 1/count.
state = state * 1664525u + 1013904223u;
const bool replace = (state % count) == 0;
if (replace) {
best_y = i;
best_x = j;
}
// Replace the current result with probability 1/count.
state = state * 1664525u + 1013904223u;
const bool replace = (state % count) == 0;
if (replace) {
best_y = i;
best_x = j;
}
}
}
}
return count == 0
? DirectResult{false, -1, -1}
: DirectResult{true, best_y, best_x};
return count == 0
? DirectResult{false, -1, -1}
: DirectResult{true, best_y, best_x};
}
// Multi-thread path: parallel count then pick
@@ -559,193 +559,257 @@ public:
}
cum += chunk_counts[t];
}
return {false, -1, -1};
}
// Sample legal positions and prefer the one whose box center is closest
// to the free-mask centroid. This gives the visually important large
// words a stable focal region without paying for a full spiral scan.
DirectResult query_near_center(int box_h, int box_w, uint32_t seed, int probes) {
flush();
int max_row = height - box_h;
int max_col = width - box_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
std::mt19937 rng(seed);
probes = std::max(16, std::min(probes, 1024));
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::infinity();
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
std::uniform_real_distribution<double> jitter(0.0, 1e-4);
// Test the centroid-aligned position first.
int center_y = std::max(0, std::min(max_row, (int)std::lround(free_center_y - box_h * 0.5)));
int center_x = std::max(0, std::min(max_col, (int)std::lround(free_center_x - box_w * 0.5)));
if (get_area_sum_fast(center_y, center_x, box_h, box_w) == 0) {
return {true, center_y, center_x};
}
const double norm_y = std::max(1.0, (double)height);
const double norm_x = std::max(1.0, (double)width);
for (int p = 0; p < probes; ++p) {
int y = row_dist(rng);
int x = col_dist(rng);
if (get_area_sum_fast(y, x, box_h, box_w) != 0) continue;
double cy = (double)y + box_h * 0.5;
double cx = (double)x + box_w * 0.5;
double dy = (cy - free_center_y) / norm_y;
double dx = (cx - free_center_x) / norm_x;
double score = dy * dy + dx * dx + jitter(rng);
if (score < best_score) {
best_score = score;
best_y = y;
best_x = x;
}
}
if (best_y >= 0) return {true, best_y, best_x};
return query_direct(box_h, box_w, seed ^ 0x9E3779B9u);
}
inline bool glyph_fits_exact(
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
) const {
for (int row = 0; row < glyph_h; ++row) {
const unsigned char* glyph_row = glyph + row * glyph_w;
const uint8_t* canvas_row = canvas.data() + (y + row) * width + x;
for (int col = 0; col < glyph_w; ++col) {
if (glyph_row[col] > 0 && canvas_row[col] != 0) return false;
}
}
return true;
}
inline void reserve_glyph_exact(
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
) {
stamp_glyph(glyph, glyph_h, glyph_w, y, x);
}
// Find and reserve a position using the actual (optionally dilated) glyph
// bitmap. Unlike rectangle queries, transparent corners and gaps between
// strokes may overlap safely. Search and reservation stay in one C++ call,
// so no integral-image rebuild is needed between words.
DirectResult place_glyph_exact(
const unsigned char* collision_glyph,
const unsigned char* stamp_glyph_data,
int glyph_h,
int glyph_w,
uint32_t seed,
int probes,
int placement_mode
) {
ensure_canvas();
const int max_row = height - glyph_h;
const int max_col = width - glyph_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
std::mt19937 rng(seed);
probes = std::max(32, std::min(probes, 2048));
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::infinity();
if (placement_mode != 0) {
const int center_y = std::max(
0, std::min(max_row, (int)std::lround(free_center_y - glyph_h * 0.5))
);
const int center_x = std::max(
0, std::min(max_col, (int)std::lround(free_center_x - glyph_w * 0.5))
);
if (glyph_fits_exact(collision_glyph, glyph_h, glyph_w, center_y, center_x)) {
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, center_y, center_x);
return {true, center_y, center_x};
}
}
const double norm_y = std::max(1.0, (double)height);
const double norm_x = std::max(1.0, (double)width);
// Mode 1 is a persistent centre-out Fermat spiral. Mode 2 skips the
// spiral and selects a random legal candidate biased toward the centre.
if (placement_mode == 1) {
constexpr double golden_angle = 2.39996322972865332;
constexpr double sample_spacing = 1.25;
const int start_step = spiral_cursor;
const int end_step = std::min(200000, start_step + 60000);
int last_y = std::numeric_limits<int>::min();
int last_x = std::numeric_limits<int>::min();
for (int step = start_step; step <= end_step; ++step) {
const double radius = sample_spacing * std::sqrt((double)step);
const double theta = golden_angle * (double)step;
const int y = (int)std::lround(
free_center_y + radius * std::sin(theta) - glyph_h * 0.5
);
const int x = (int)std::lround(
free_center_x + radius * std::cos(theta) - glyph_w * 0.5
);
if (y == last_y && x == last_x) continue;
last_y = y;
last_x = x;
if (y < 0 || x < 0 || y > max_row || x > max_col) continue;
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
spiral_cursor = step;
return {true, y, x};
}
}
for (int probe = 0; probe < probes; ++probe) {
const int y = row_dist(rng);
const int x = col_dist(rng);
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
if (placement_mode == 0) {
stamp_glyph(stamp_glyph_data, glyph_h, glyph_w, y, x);
return {true, y, x};
}
const double cy = (double)y + glyph_h * 0.5;
const double cx = (double)x + glyph_w * 0.5;
const double dy = (cy - free_center_y) / norm_y;
const double dx = (cx - free_center_x) / norm_x;
const double score = dy * dy + dx * dx;
if (score < best_score) {
best_score = score;
best_y = y;
best_x = x;
}
}
if (best_y >= 0) {
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, best_y, best_x);
return {true, best_y, best_x};
}
// Dense late-stage fallback. Start at a seeded offset to avoid a
// top-left bias, but visit every possible origin so completeness is
// deterministic whenever a legal position exists.
const int n_rows = max_row + 1;
const int n_cols = max_col + 1;
const int row_start = row_dist(rng);
const int col_start = col_dist(rng);
for (int row_offset = 0; row_offset < n_rows; ++row_offset) {
const int y = (row_start + row_offset) % n_rows;
for (int col_offset = 0; col_offset < n_cols; ++col_offset) {
const int x = (col_start + col_offset) % n_cols;
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
return {true, y, x};
}
}
return {false, -1, -1};
}
// =========================================================
return {false, -1, -1};
}
// Sample legal positions and prefer the one whose box center is closest
// to the free-mask centroid. This gives the visually important large
// words a stable focal region without paying for a full spiral scan.
DirectResult query_near_center(int box_h, int box_w, uint32_t seed, int probes) {
flush();
int max_row = height - box_h;
int max_col = width - box_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
std::mt19937 rng(seed);
probes = std::max(16, std::min(probes, 1024));
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::infinity();
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
std::uniform_real_distribution<double> jitter(0.0, 1e-4);
// Test the centroid-aligned position first.
int center_y = std::max(0, std::min(max_row, (int)std::lround(free_center_y - box_h * 0.5)));
int center_x = std::max(0, std::min(max_col, (int)std::lround(free_center_x - box_w * 0.5)));
if (get_area_sum_fast(center_y, center_x, box_h, box_w) == 0) {
return {true, center_y, center_x};
}
const double norm_y = std::max(1.0, (double)height);
const double norm_x = std::max(1.0, (double)width);
for (int p = 0; p < probes; ++p) {
int y = row_dist(rng);
int x = col_dist(rng);
if (get_area_sum_fast(y, x, box_h, box_w) != 0) continue;
double cy = (double)y + box_h * 0.5;
double cx = (double)x + box_w * 0.5;
double dy = (cy - free_center_y) / norm_y;
double dx = (cx - free_center_x) / norm_x;
double score = dy * dy + dx * dx + jitter(rng);
if (score < best_score) {
best_score = score;
best_y = y;
best_x = x;
}
}
if (best_y >= 0) return {true, best_y, best_x};
return query_direct(box_h, box_w, seed ^ 0x9E3779B9u);
}
inline bool glyph_fits_exact(
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
) const {
for (int row = 0; row < glyph_h; ++row) {
const unsigned char* glyph_row = glyph + row * glyph_w;
const uint8_t* canvas_row = canvas.data() + (y + row) * width + x;
for (int col = 0; col < glyph_w; ++col) {
if (glyph_row[col] > 0 && canvas_row[col] != 0) return false;
}
}
return true;
}
inline void reserve_glyph_exact(
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
) {
stamp_glyph(glyph, glyph_h, glyph_w, y, x);
}
// Find and reserve a position using the actual (optionally dilated) glyph
// bitmap. Unlike rectangle queries, transparent corners and gaps between
// strokes may overlap safely. Search and reservation stay in one C++ call,
// so no integral-image rebuild is needed between words.
DirectResult place_glyph_exact(
const unsigned char* collision_glyph,
const unsigned char* stamp_glyph_data,
int glyph_h,
int glyph_w,
uint32_t seed,
int probes,
int placement_mode
) {
ensure_canvas();
const int max_row = height - glyph_h;
const int max_col = width - glyph_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
std::mt19937 rng(seed);
probes = std::max(32, std::min(probes, 2048));
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::infinity();
// Only the spiral (mode 1) may anchor its first probe on the exact
// mask centroid; that is the "small words spiral out from the centre"
// behaviour we want. Mode 2 (large words) deliberately skips it: when
// every mode kept this shortcut, whichever word happened to be placed
// first landed on the same centroid pixel in every single generation,
// regardless of layout_seed, giving every export an unmoving bullseye.
if (placement_mode == 1) {
const int center_y = std::max(
0, std::min(max_row, (int)std::lround(free_center_y - glyph_h * 0.5))
);
const int center_x = std::max(
0, std::min(max_col, (int)std::lround(free_center_x - glyph_w * 0.5))
);
if (glyph_fits_exact(collision_glyph, glyph_h, glyph_w, center_y, center_x)) {
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, center_y, center_x);
return {true, center_y, center_x};
}
}
const double norm_y = std::max(1.0, (double)height);
const double norm_x = std::max(1.0, (double)width);
// Mode 1 is a persistent centre-out Fermat spiral. Mode 2 skips the
// spiral and places the word by random probe.
if (placement_mode == 1) {
constexpr double golden_angle = 2.39996322972865332;
constexpr double sample_spacing = 1.25;
constexpr double two_pi = 6.283185307179586;
// Each word starts its sweep at a random angle instead of exactly
// golden_angle past the previous one, so consecutive words are no
// longer locked into the fixed angular step that draws a textbook
// Vogel/sunflower figure. The draw comes from `rng`, already seeded
// per word from layout_seed, so a given seed still reproduces
// exactly, and different seeds now give genuinely different
// arrangements rather than the same figure with the names permuted.
//
// The offset deliberately spans the full circle. A *bounded* offset
// was tried and is much worse than useless: confining the sweep to a
// wedge makes a word skip positions at the packed frontier and
// settle for a worse one, which measured a 31% loss of final ink
// density (0.255 -> 0.176) on an 800-word cloud. Spanning the whole
// circle costs nothing, because the sweep still reaches every angle
// as the radius grows.
//
// Note this does not make the cloud look unstructured. Radius still
// tracks placement order closely (Pearson r ~= 0.98), because a
// centre-out fill that stays dense has to grow outward in order --
// the ordering and the density are the same property. Breaking that
// appearance without paying for it needs several spiral origins
// rather than jitter on one, which is a larger change than this.
std::uniform_real_distribution<double> phase_jitter(0.0, two_pi);
const double theta_offset = phase_jitter(rng);
const int start_step = spiral_cursor;
const int end_step = std::min(200000, start_step + 60000);
int last_y = std::numeric_limits<int>::min();
int last_x = std::numeric_limits<int>::min();
for (int step = start_step; step <= end_step; ++step) {
const double radius = sample_spacing * std::sqrt((double)step);
const double theta = golden_angle * (double)step + theta_offset;
const int y = (int)std::lround(
free_center_y + radius * std::sin(theta) - glyph_h * 0.5
);
const int x = (int)std::lround(
free_center_x + radius * std::cos(theta) - glyph_w * 0.5
);
if (y == last_y && x == last_x) continue;
last_y = y;
last_x = x;
if (y < 0 || x < 0 || y > max_row || x > max_col) continue;
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
spiral_cursor = step;
return {true, y, x};
}
}
// Soft radial bound for mode 2. Pure first-accept scatter over the whole
// canvas let large words land far outside the crowd, leaving detached
// stragglers and a ragged silhouette. Draws beyond the bound are
// rejected, but the bound widens as the probe budget is consumed and is
// gone entirely for the last quarter of the probes, so this only biases
// *where* a word prefers to land -- it never removes a legal position
// and so cannot cost completeness.
const double mask_radius = 0.5 * std::sqrt(
(double)height * (double)height + (double)width * (double)width
);
for (int probe = 0; probe < probes; ++probe) {
const int y = row_dist(rng);
const int x = col_dist(rng);
if (placement_mode == 2) {
const double frac = (double)probe / (double)probes;
if (frac < 0.75) {
// 0.55 -> 1.0 of the mask radius over the first 75% of probes.
const double limit = mask_radius * (0.55 + 0.60 * frac);
const double cy = (double)y + glyph_h * 0.5 - free_center_y;
const double cx = (double)x + glyph_w * 0.5 - free_center_x;
if (cy * cy + cx * cx > limit * limit) continue;
}
}
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
if (placement_mode == 0) {
stamp_glyph(stamp_glyph_data, glyph_h, glyph_w, y, x);
return {true, y, x};
}
// Mode 2 (large words) takes the first legal random draw inside the
// radial bound above. It used to scan the whole probe budget and keep
// the candidate closest to the mask centroid, which packed every
// large word into one tight rosette at the centre -- the innermost
// radial shell held no spiral words at all, leaving a hard seam
// between a dense core and the spiral field. First-accept scatters
// them across the mass as intended, and is cheaper: it can return on
// the first hit instead of always running all `probes` fit tests.
if (placement_mode == 2) {
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
return {true, y, x};
}
const double cy = (double)y + glyph_h * 0.5;
const double cx = (double)x + glyph_w * 0.5;
const double dy = (cy - free_center_y) / norm_y;
const double dx = (cx - free_center_x) / norm_x;
const double score = dy * dy + dx * dx;
if (score < best_score) {
best_score = score;
best_y = y;
best_x = x;
}
}
if (best_y >= 0) {
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, best_y, best_x);
return {true, best_y, best_x};
}
// Dense late-stage fallback. Start at a seeded offset to avoid a
// top-left bias, but visit every possible origin so completeness is
// deterministic whenever a legal position exists.
const int n_rows = max_row + 1;
const int n_cols = max_col + 1;
const int row_start = row_dist(rng);
const int col_start = col_dist(rng);
for (int row_offset = 0; row_offset < n_rows; ++row_offset) {
const int y = (row_start + row_offset) % n_rows;
for (int col_offset = 0; col_offset < n_cols; ++col_offset) {
const int x = (col_start + col_offset) % n_cols;
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
return {true, y, x};
}
}
return {false, -1, -1};
}
// =========================================================
// v3: Batch query — process multiple (box_h, box_w) in one call
// Returns vector of {found, y, x} for each query
// =========================================================
@@ -786,13 +850,13 @@ public:
return {false, -1, -1, end_idx};
}
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
}
{
std::shared_lock<std::shared_mutex> lock(mutex_);
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
}
{
std::shared_lock<std::shared_mutex> lock(mutex_);
if (dirty_count > 0 && dirty_count >= rebuild_interval) {
lock.unlock();
std::unique_lock<std::shared_mutex> write_lock(mutex_);
@@ -894,67 +958,67 @@ static PyObject* Grid_query_reservoir(PyIntegralGrid* self, PyObject* args) {
}
// v3: Direct pixel-grid scan with parallel counting
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w;
unsigned int seed = 0;
if (!PyArg_ParseTuple(args, "ii|I", &box_h, &box_w, &seed)) return NULL;
auto r = self->grid->query_direct(box_h, box_w, seed);
auto r = self->grid->query_direct(box_h, box_w, seed);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
static PyObject* Grid_query_near_center(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w;
unsigned int seed = 0;
int probes = 160;
if (!PyArg_ParseTuple(args, "ii|Ii", &box_h, &box_w, &seed, &probes)) return NULL;
auto r = self->grid->query_near_center(box_h, box_w, seed, probes);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
static PyObject* Grid_place_glyph_exact(PyIntegralGrid* self, PyObject* args) {
PyObject* collision_obj;
PyObject* stamp_obj;
int glyph_h, glyph_w;
unsigned int seed = 0;
int probes = 256;
int placement_mode = 0;
if (!PyArg_ParseTuple(
args, "OOiiIii", &collision_obj, &stamp_obj, &glyph_h, &glyph_w, &seed, &probes, &placement_mode
)) return NULL;
Py_buffer collision_view;
Py_buffer stamp_view;
if (PyObject_GetBuffer(collision_obj, &collision_view, PyBUF_SIMPLE) < 0) return NULL;
if (PyObject_GetBuffer(stamp_obj, &stamp_view, PyBUF_SIMPLE) < 0) {
PyBuffer_Release(&collision_view);
return NULL;
}
const Py_ssize_t required = (Py_ssize_t)glyph_h * (Py_ssize_t)glyph_w;
if (glyph_h <= 0 || glyph_w <= 0 || collision_view.len < required || stamp_view.len < required) {
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
PyErr_SetString(PyExc_ValueError, "glyph buffer is smaller than glyph_h * glyph_w");
return NULL;
}
auto result = self->grid->place_glyph_exact(
(const unsigned char*)collision_view.buf,
(const unsigned char*)stamp_view.buf,
glyph_h,
glyph_w,
seed,
probes,
placement_mode
);
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
if (result.found) return Py_BuildValue("ii", result.y, result.x);
Py_RETURN_NONE;
}
Py_RETURN_NONE;
}
static PyObject* Grid_query_near_center(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w;
unsigned int seed = 0;
int probes = 160;
if (!PyArg_ParseTuple(args, "ii|Ii", &box_h, &box_w, &seed, &probes)) return NULL;
auto r = self->grid->query_near_center(box_h, box_w, seed, probes);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
static PyObject* Grid_place_glyph_exact(PyIntegralGrid* self, PyObject* args) {
PyObject* collision_obj;
PyObject* stamp_obj;
int glyph_h, glyph_w;
unsigned int seed = 0;
int probes = 256;
int placement_mode = 0;
if (!PyArg_ParseTuple(
args, "OOiiIii", &collision_obj, &stamp_obj, &glyph_h, &glyph_w, &seed, &probes, &placement_mode
)) return NULL;
Py_buffer collision_view;
Py_buffer stamp_view;
if (PyObject_GetBuffer(collision_obj, &collision_view, PyBUF_SIMPLE) < 0) return NULL;
if (PyObject_GetBuffer(stamp_obj, &stamp_view, PyBUF_SIMPLE) < 0) {
PyBuffer_Release(&collision_view);
return NULL;
}
const Py_ssize_t required = (Py_ssize_t)glyph_h * (Py_ssize_t)glyph_w;
if (glyph_h <= 0 || glyph_w <= 0 || collision_view.len < required || stamp_view.len < required) {
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
PyErr_SetString(PyExc_ValueError, "glyph buffer is smaller than glyph_h * glyph_w");
return NULL;
}
auto result = self->grid->place_glyph_exact(
(const unsigned char*)collision_view.buf,
(const unsigned char*)stamp_view.buf,
glyph_h,
glyph_w,
seed,
probes,
placement_mode
);
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
if (result.found) return Py_BuildValue("ii", result.y, result.x);
Py_RETURN_NONE;
}
// v3: Batch query — process multiple placements in one C++ call
// Input: list of (box_h, box_w, seed) tuples
@@ -1031,16 +1095,16 @@ static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject
Py_RETURN_NONE;
}
// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region.
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region.
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
PyObject* glyph_obj;
int gh, gw, pos_r, pos_c;
if (!PyArg_ParseTuple(args, "Oiiii", &glyph_obj, &gh, &gw, &pos_r, &pos_c)) return NULL;
Py_buffer view;
if (PyObject_GetBuffer(glyph_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
self->grid->rebuild_from_canvas(pos_r, pos_c);
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
self->grid->rebuild_from_canvas(pos_r, pos_c);
PyBuffer_Release(&view);
Py_RETURN_NONE;
@@ -1049,10 +1113,10 @@ static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
static PyMethodDef Grid_methods[] = {
{"reorder_stratified", (PyCFunction)Grid_reorder_stratified, METH_VARARGS, "Reorder valid coords with stratified interleaving."},
{"query_sorted", (PyCFunction)Grid_query_sorted, METH_VARARGS, "Find position using sorted coordinate list (center-out, parallel)."},
{"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."},
{"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."},
{"query_near_center", (PyCFunction)Grid_query_near_center, METH_VARARGS, "Sample legal positions and prefer the free-mask centroid."},
{"place_glyph_exact", (PyCFunction)Grid_place_glyph_exact, METH_VARARGS, "Place and reserve an exact glyph bitmap without changing its size."},
{"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."},
{"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."},
{"query_near_center", (PyCFunction)Grid_query_near_center, METH_VARARGS, "Sample legal positions and prefer the free-mask centroid."},
{"place_glyph_exact", (PyCFunction)Grid_place_glyph_exact, METH_VARARGS, "Place and reserve an exact glyph bitmap without changing its size."},
{"batch_query", (PyCFunction)Grid_batch_query, METH_VARARGS, "Batch placement: list of (bh,bw,seed) -> list of (y,x)|None."},
{"update", (PyCFunction)Grid_update, METH_VARARGS, "Update grid with placed rectangle."},
{"flush", (PyCFunction)Grid_flush, METH_NOARGS, "Force rebuild integral image."},
+20 -2
View File
@@ -46,6 +46,8 @@ BASE_HD_HEIGHT = 4000
MIN_READABLE_HEIGHT_PX = 22
# 运算网格缩放:0.18 在速度/质量之间更均衡
WORK_SCALE = 0.18
# 服务端快速预览路径:减少试探次数,但保留真实字形碰撞和零重叠校验。
FAST_MODE = False
# --- 阴阳刻 ---
FILL_ON = "BLACK"
@@ -67,11 +69,19 @@ FONT_FALLBACK_PATHS = (
# --- 填充策略 ---
N_REPETITIONS = 1
# 名单较少、掩膜轮廓填不满时,自动循环追加名字副本增加词数,让费马螺旋
# 能走到掩膜远端(心形尖端、人物四肢),把形状填出来而非退化成圆形。
# AUTO_REPEAT_MAX 是自动重复次数的安全上限,避免无限追加。
AUTO_REPEAT_TO_FILL = True
AUTO_REPEAT_MAX = 20
# 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。
# 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。
TARGET_FILL_RATIO = 0.45
SIZE_RATIO = 2.0
PACKING_EFFICIENCY = 0.9
# 竖排概率:每个词独立以此概率竖着摆放,其余水平摆放。
# 0.0 = 全部水平,1.0 = 全部竖排。适度混排可打散过于规整的观感。
VERTICAL_RATIO = 0.18
# --- 智能字号搜索 ---
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
@@ -117,11 +127,12 @@ LAYOUT_SEED = None
KNOWN_CONFIG_KEYS = {
'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'FILL_CORNERS',
'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE',
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FILL_ON', 'EXCEL_PATH',
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FAST_MODE', 'FILL_ON', 'EXCEL_PATH',
'DATA_COL_INDEX', 'WEIGHT_COL_INDEX', 'WEIGHT_COL_NAME', 'REMOVE_DUPLICATES', 'ENABLE_STROKE_WEIGHTS',
'WC_FONT_PATH',
'FONT_FALLBACK_PATHS',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'VERTICAL_RATIO',
'AUTO_REPEAT_TO_FILL', 'AUTO_REPEAT_MAX',
'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE',
'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH',
'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'OUTPUT_DIR', 'OUTPUT_PREFIX',
@@ -144,11 +155,15 @@ CONFIG_ALIASES = {
'max_font_size': 'USER_MAX_FONT_SIZE',
'font_color': 'FONT_COLOR',
'stroke_weights': 'ENABLE_STROKE_WEIGHTS',
'auto_repeat_to_fill': 'AUTO_REPEAT_TO_FILL',
'auto_repeat_max': 'AUTO_REPEAT_MAX',
'save_debug_images': 'SAVE_DEBUG_IMAGES',
}
CRITICAL_TYPE_CHECKS = {
'MODE': str,
'WORK_SCALE': (int, float),
'FAST_MODE': bool,
'DATA_COL_INDEX': int,
'WEIGHT_COL_INDEX': (int, type(None)),
'WEIGHT_COL_NAME': (str, type(None)),
@@ -160,6 +175,9 @@ CRITICAL_TYPE_CHECKS = {
'ENABLE_STROKE_WEIGHTS': bool,
'CANVAS_RETRY_MAX_ROUNDS': int,
'CANVAS_RETRY_GROWTH': (int, float),
'AUTO_REPEAT_TO_FILL': bool,
'AUTO_REPEAT_MAX': int,
'VERTICAL_RATIO': (int, float),
'SEED': (int, type(None)),
'LAYOUT_SEED': (int, type(None)),
}
+220 -83
View File
@@ -42,65 +42,169 @@ def _load_ft_font(font_path):
def build_svg_text_path_cached(word, size, x, y, font_path, orient):
"""Return (path_d, tx, ty, None). Geometry is cached for identical glyphs.
"""Return (path_d, tx, ty, None) for the whole word as a single path.
Path is in y-up font space (same as matplotlib TextPath). Caller applies
translate(tx, ty) scale(1, -1) to place it on the canvas.
Kept for callers that want one path per word. Internally this concatenates
the per-character parts, which are cached and shared across every word that
reuses the character.
"""
parts, tx0, ty0 = build_svg_word_parts(word, size, font_path, orient)
if not parts:
return "", x, y, None
if len(parts) == 1 and parts[0][1] == 0.0 and parts[0][2] == 0.0:
return parts[0][0], tx0 + x, ty0 + y, None
# Only reached when a caller insists on one path per word; the per-part
# translate has to be baked into the coordinates, so this is the slow path.
merged = " ".join(_translate_path_d(d, dx, dy) for d, dx, dy in parts)
return merged, tx0 + x, ty0 + y, None
def build_svg_word_parts(word, size, font_path, orient):
"""Return ([(path_d, dx, dy), ...], tx0, ty0) for one word.
Each part is a per-character outline cached at cursor 0 and reused verbatim;
dx/dy carry that character's position within the word. A caller places a
part with translate(tx0 + x + dx, ty0 + y + dy) scale(1, -1).
Caching per character rather than per word is what makes export cheap: a
roster of 750 Chinese names contains only a few dozen distinct characters,
so the outline drawing and the path-string formatting run a few dozen times
instead of once per name.
"""
key = (font_path, word, int(size), bool(orient))
cached = _SVG_SHAPE_CACHE.get(key)
if cached is None:
try:
cached = _build_shape_fonttools(word, size, font_path, orient)
except Exception:
cached = _build_shape_matplotlib(word, size, font_path, orient)
_SVG_SHAPE_CACHE[key] = cached
if len(_SVG_SHAPE_CACHE) > 20000:
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
if i % 2 == 0:
_SVG_SHAPE_CACHE.pop(k, None)
path_d, tx0, ty0 = cached
return path_d, tx0 + x, ty0 + y, None
if cached is not None:
return cached
parts = []
cursor = 0.0
xmin = ymin = float("inf")
xmax = ymax = float("-inf")
for ch in word:
shape = _char_shape(ch, size, font_path, orient)
if shape is None:
continue
path_d, advance, cxmin, cymin, cxmax, cymax = shape
if path_d:
# A character's outline is cached at cursor 0; the cursor becomes a
# translate offset so the cached string is reused byte for byte.
# Horizontal runs advance in +x, rotated runs in +y (the rotated
# glyph transform subtracts the cursor from y, and the outer
# scale(1, -1) flips that back to +y).
dx, dy = (0.0, cursor) if orient else (cursor, 0.0)
parts.append((path_d, dx, dy))
if orient:
xmin = min(xmin, cxmin)
xmax = max(xmax, cxmax)
ymin = min(ymin, cymin - cursor)
ymax = max(ymax, cymax - cursor)
else:
xmin = min(xmin, cxmin + cursor)
xmax = max(xmax, cxmax + cursor)
ymin = min(ymin, cymin)
ymax = max(ymax, cymax)
cursor += advance
if not parts:
result = ([], 0.0, 0.0)
else:
# Offsets placing the run's top-left at (0,0) under
# translate(tx,ty) scale(1,-1).
result = (parts, -xmin, ymax)
_SVG_SHAPE_CACHE[key] = result
if len(_SVG_SHAPE_CACHE) > 20000:
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
if i % 2 == 0:
_SVG_SHAPE_CACHE.pop(k, None)
return result
def _build_shape_fonttools(word, size, font_path, orient):
def _char_shape(ch, size, font_path, orient):
"""Return (path_d, advance, xmin, ymin, xmax, ymax) for one character.
The outline is drawn at cursor 0 and scaled to `size`, so the same string is
valid at every position the character appears in.
"""
key = (font_path, ch, int(size), bool(orient))
cached = _FT_CHAR_PATH_CACHE.get(key)
if cached is not None:
return cached
try:
cached = _build_char_fonttools(ch, size, font_path, orient)
except Exception:
cached = _build_char_matplotlib(ch, size, font_path, orient)
_FT_CHAR_PATH_CACHE[key] = cached
return cached
def _build_char_fonttools(ch, size, font_path, orient):
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.misc.transform import Transform
_tt, glyph_set, cmap, units = _load_ft_font(font_path)
scale = float(size) / float(units)
gname = cmap.get(ord(ch))
if not gname or gname not in glyph_set:
return None
glyph = glyph_set[gname]
pen = SVGPathPen(glyph_set)
cursor = 0.0
for ch in word:
gname = cmap.get(ord(ch))
if not gname or gname not in glyph_set:
continue
glyph = glyph_set[gname]
if orient:
# Horizontal layout then rotate -90° around origin:
# point (px, py) in string space -> after scale: (s*px, s*py)
# rotate -90: (s*py, -s*px). Compose with glyph origin at cursor:
# glyph local (gx,gy) -> (scale*gx + cursor, scale*gy)
# -> rotate -90: (scale*gy, -(scale*gx + cursor)) = (scale*gy, -scale*gx - cursor)
# matrix: x' = 0*gx + scale*gy + 0; y' = -scale*gx + 0*gy - cursor
# Transform(xx, xy, yx, yy, dx, dy): x' = xx*x + xy*y + dx; y' = yx*x + yy*y + dy
# xx=0, xy=scale, yx=-scale, yy=0, dx=0, dy=-cursor
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, -cursor))
else:
tp = TransformPen(pen, Transform(scale, 0, 0, scale, cursor, 0))
glyph.draw(tp)
cursor += float(glyph.width) * scale
if orient:
# Horizontal layout then rotate -90° around the origin. The cursor term
# that used to live in dy is applied by the caller as a translate.
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, 0))
else:
tp = TransformPen(pen, Transform(scale, 0, 0, scale, 0, 0))
glyph.draw(tp)
path_d = pen.getCommands()
advance = float(glyph.width) * scale
if not path_d:
return "", 0.0, 0.0
return "", advance, 0.0, 0.0, 0.0, 0.0
xmin, ymin, xmax, ymax = _path_bbox(path_d)
# Offsets placing glyph top-left at (0,0) under translate(tx,ty) scale(1,-1)
tx0 = -xmin
ty0 = ymax
return path_d, tx0, ty0
return path_d, advance, xmin, ymin, xmax, ymax
def _build_char_matplotlib(ch, size, font_path, orient):
from matplotlib.textpath import TextPath
path = TextPath((0, 0), ch, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
path_d = mpl_path_to_svg_d(path)
# matplotlib gives no advance width; the ink bbox is the best stand-in.
advance = (bbox.ymax - bbox.ymin) if orient else (bbox.xmax - bbox.xmin)
return path_d, advance, bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax
def _translate_path_d(path_d, dx, dy):
"""Shift every coordinate pair in an SVG path string by (dx, dy).
Only used by the single-path-per-word compatibility path; the fast export
route carries dx/dy in the element transform instead.
"""
if not dx and not dy:
return path_d
import re
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
out = []
i = 0
while i < len(tokens):
t = tokens[i]
if not t.isalpha():
i += 1
continue
out.append(t)
i += 1
coords = []
while i < len(tokens) and not tokens[i].isalpha():
coords.append(float(tokens[i]))
i += 1
for j, v in enumerate(coords):
out.append(f"{v + (dx if j % 2 == 0 else dy):g}")
return " ".join(out)
def _path_bbox(path_d):
@@ -137,19 +241,6 @@ def _path_bbox(path_d):
return min(xs), min(ys), max(xs), max(ys)
def _build_shape_matplotlib(word, size, font_path, orient):
from matplotlib.textpath import TextPath
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
tx0 = -bbox.xmin
ty0 = bbox.ymax
path_d = mpl_path_to_svg_d(path)
return path_d, tx0, ty0
def build_svg_text_path(word, size, x, y, font_path, orient):
path_d, tx, ty, _ = build_svg_text_path_cached(word, size, x, y, font_path, orient)
return path_d, tx, ty, None
@@ -324,8 +415,44 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f_size = min(max_font, max(min_font, int(round(raw_size))))
target_font_sizes.append(f_size)
random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08)))
for idx, (word, _freq) in enumerate(layout_sequence):
# "Large" words go down first by random probe (mode 2), then everything
# else spirals out from the centre to fill around them (mode 1). Which
# words count as large is decided by their actual font size, not by
# their position in the shuffled sequence: the old rule took the first
# 8% of the sequence, which under equal weights is an arbitrary set of
# same-size words, so "large" placement was applied to words that were
# not large at all. When every word is the same size (the equal-weight
# case) there is no large tier and everything spirals, which is the
# correct degenerate behaviour.
large_font_cutoff = min_font + base_span * 0.80
large_indices = [
idx for idx, size in enumerate(target_font_sizes)
if base_span > 0 and size >= large_font_cutoff
]
# Placing the large words before the small ones matters: they need whole
# empty regions to land in, and once the spiral has packed the canvas
# there are none left. Ordering is by index within each tier so a given
# layout_seed still reproduces exactly.
large_index_set = set(large_indices)
placement_order = large_indices + [
idx for idx in range(len(layout_sequence)) if idx not in large_index_set
]
# A word is only reported unplaced after the spiral, the random probes
# and a full exhaustive scan have all failed, so a single failure proves
# no legal position exists for it at this size -- and since the pipeline
# requires every word, the whole batch is already doomed. `max_failures`
# lets the caller stop right there instead of finishing the batch, which
# is what makes the scale search cheap: an unplaceable word costs about
# ten times a placeable one (it pays the full search before giving up),
# so a doomed batch run to completion is by far the most expensive thing
# the pipeline can do. Left as None, the batch runs to the end and packs
# in as many words as it can.
max_failures = getattr(self, "max_failures", None)
failures = 0
for idx in placement_order:
word, _freq = layout_sequence[idx]
font_size = target_font_sizes[idx]
placed = False
rotate = rotation_flags[idx]
@@ -344,8 +471,7 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
pad,
) = measured
query_seed = int(rng.integers(0, 2**31))
large_word = idx < random_large_prefix or score_by_index[idx] >= 0.80
placement_mode = 2 if large_word else 1
placement_mode = 2 if idx in large_index_set else 1
pos = self.grid.place_glyph_exact(
collision_arr,
stamp_arr,
@@ -371,6 +497,10 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
# Deliberately do not shrink an individual word. The pipeline
# treats a short layout as a failed batch and retries every word
# at one uniformly scaled size range.
if not placed:
failures += 1
if max_failures is not None and failures >= max_failures:
break
return self
@@ -385,28 +515,27 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
return img
def _iter_svg_paths(self):
"""Build SVG path data once per layout entry, with glyph-shape cache."""
# Cache by (word, size, orient): path geometry is identical; only translate differs.
shape_cache = {}
"""Yield (path_d, tx, ty, color), one entry per glyph.
A word contributes one entry per character. Each path string comes
straight from the per-character cache and the character's position
within the word rides along in tx/ty, so no path data is rebuilt or
re-parsed per word.
"""
for word, size, (y, x), orient, color in self.layout_:
key = (word, int(size), bool(orient))
cached = shape_cache.get(key)
if cached is None:
try:
path_d, origin_tx, origin_ty, bbox = build_svg_text_path_cached(
word, size, 0, 0, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
# origin_tx/ty place the glyph so its top-left is at (0,0)
shape_cache[key] = (path_d, origin_tx, origin_ty)
cached = shape_cache[key]
path_d, origin_tx, origin_ty = cached
# Shift from (0,0) origin to actual layout position
tx = origin_tx + x
ty = origin_ty + y
yield path_d, tx, ty, color
try:
parts, origin_tx, origin_ty = build_svg_word_parts(
word, size, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
# origin_tx/ty place the run's top-left at (0,0); x/y move it to the
# layout position; dx/dy offset the character within the run.
base_tx = origin_tx + x
base_ty = origin_ty + y
for path_d, dx, dy in parts:
yield path_d, base_tx + dx, base_ty + dy, color
def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0):
"""Write fill + stroke SVG in one pass (path geometry built once)."""
@@ -500,13 +629,21 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
ring_radius=3, ring_width=1, ring_spacing=8):
"""统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。"""
# 预先构建所有文字路径(fill / dot 模式共用)
# One entry per glyph rather than per word: each character's outline is
# taken straight from the shared cache, with its position in the run
# carried in tx/ty. Consumers below only ever place these as separate
# <path> elements, so splitting a word costs nothing.
text_paths = []
for word, size, (y, x), orient, _color in self.layout_:
try:
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
text_paths.append((path, tx, ty))
parts, origin_tx, origin_ty = build_svg_word_parts(
word, size, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
continue
for path, dx, dy in parts:
text_paths.append((path, origin_tx + x + dx, origin_ty + y + dy))
with open(filename, "w", encoding="utf-8") as f:
f.write(
+314 -85
View File
@@ -15,10 +15,13 @@ from .fonts import get_cached_font
from .layout import OptimizedEfficientWordCloud
from .mask import analyze_mask, apply_safe_padding, calculate_dynamic_dimensions, prepare_mask
from .render import (
compute_coverage_score,
compute_fill_ratio_fast,
count_layout_overlap_pixels,
largest_empty_square_size,
append_layout_with_hd_clearance,
refine_layout_with_hd_clearance,
render_layout_occupancy,
scale_layout_for_hd,
)
from .weights import (
@@ -127,7 +130,7 @@ def run_generation_pass(
max_font = min(max_font, hard_max_font)
return min_font, max(min_font, max_font)
def try_place(scale, layout_seed=base_layout_seed):
def try_place(scale, layout_seed=base_layout_seed, probe=False):
min_font, max_font = scaled_bounds(scale)
wc = OptimizedEfficientWordCloud(
width=w_small,
@@ -138,12 +141,23 @@ def run_generation_pass(
min_font_size=min_font,
max_font_size=max_font,
background_color=config.get_output_background(),
prefer_horizontal=0.82,
# Each word independently draws horizontal vs vertical, so the mix
# is scattered rather than banded. VERTICAL_RATIO is the chance of
# a vertical word; mixing orientations is one of the cheapest ways
# to break up an over-regular grid-like look.
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
# HD clearance is applied after scaling. Keeping the coarse-grid
# margin at zero avoids turning 1 HD pixel into 5-6 output pixels.
margin=_collision_margin,
)
wc.layout_seed = layout_seed
# A probe only needs to answer "does every word fit at this scale?", so
# it stops at the first word that cannot be placed. The answer is exact
# -- a word is only reported unplaced once an exhaustive scan has ruled
# out every position -- and it avoids paying for a doomed batch's
# remaining failures, each of which is far more expensive than a
# successful placement.
wc.max_failures = 1 if probe else None
wc.generate_from_frequencies(frequencies_data)
return wc, len(wc.layout_), min_font, max_font
@@ -165,55 +179,99 @@ def run_generation_pass(
best_wc = None
best_count = 0
best_scale = 1.0
scale = 1.0
best_coverage = -1.0 # shape-aware coverage of the current best candidate
best_occ = None # occupancy raster of the current best candidate
tried_layouts = set()
failed_scales = []
for attempt in range(1, 4):
attempt = 0
def probe_scale(scale):
"""Lay out every word at `scale`; return (wc, placed_count, complete)."""
nonlocal attempt, best_wc, best_count, best_scale
nonlocal best_coverage, best_occ
attempt += 1
bounds = scaled_bounds(scale)
layout_key = (bounds, base_layout_seed)
if layout_key in tried_layouts:
break
tried_layouts.add(layout_key)
wc, placed_count, min_font, max_font = try_place(scale)
tried_layouts.add((bounds, base_layout_seed))
wc, placed_count, min_font, max_font = try_place(scale, probe=True)
complete = placed_count >= total_target
print(
f" 整批布局 #{attempt}: scale={scale:.3f}, "
f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}"
f"字号=[{min_font}, {max_font}] -> "
f"{'完整' if complete else '不足'} ({placed_count}/{total_target})"
)
log.info(
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d",
attempt,
scale,
min_font,
max_font,
placed_count,
total_target,
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d complete=%s",
attempt, scale, min_font, max_font, placed_count, total_target, complete,
)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = scale
if placed_count >= total_target:
final_wc = wc
final_scale = scale
final_layout_seed = base_layout_seed
if complete:
# Among complete layouts prefer the one whose ink reaches furthest
# into the mask shape, not merely the largest font scale. A Fermat
# spiral packs words into a disc around the fillable centroid; with
# few words or large fonts that disc stays small and never reaches
# the mask's protrusions, so the cloud reads as a circle instead of
# the intended silhouette. Coverage rewards layouts that spread into
# those deep regions, letting a slightly smaller scale win when it
# trades font size for a recognisable outline. Scale is the tie-
# breaker so an equal-coverage denser picture is still preferred.
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
coverage = compute_coverage_score(occ, mask_small)
log.info(
" 整批布局 #%d coverage=%.4f (best=%.4f)",
attempt, coverage, best_coverage,
)
if coverage > best_coverage or (
coverage == best_coverage and scale > best_scale
):
best_wc, best_count, best_scale = wc, placed_count, scale
best_coverage, best_occ = coverage, occ
if not complete:
failed_scales.append(scale)
return wc, placed_count, complete
# Find the largest scale at which every word still fits. Bigger is strictly
# better here: the same names drawn larger leave less blank space. A probe
# answers feasibility exactly and stops at the first unplaceable word, so
# searching for the best scale costs little more than accepting the first
# one that happens to work.
lo = None # largest scale known to fit everything
hi = None # smallest scale known to be too big
scale = 1.0
for _ in range(2 if config.FAST_MODE else 4):
wc, placed_count, complete = probe_scale(scale)
if complete:
lo = scale
break
failed_scales.append(scale)
hi = scale
placed_ratio = placed_count / max(1, total_target)
# Required box area is roughly proportional to size². The extra
# safety margin absorbs fragmentation without wasting a binary search.
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.58, math.sqrt(placed_ratio) * 0.92))
# Area scales with size², so linear size scales with sqrt(ratio). The
# probe stops early, which understates how many words would have fit,
# so this deliberately undershoots and the bisection below climbs back.
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.55, math.sqrt(placed_ratio) * 0.92))
scale *= shrink
if final_wc is None:
# Close the gap between the largest failing scale and the smallest passing
# one. Each step recovers font size that the shrink above gave away.
if lo is not None and hi is not None:
for _ in range(1 if config.FAST_MODE else 3):
mid = (lo + hi) / 2.0
if hi - lo < 0.02 or scaled_bounds(mid) == scaled_bounds(lo):
break
_wc, _placed, complete = probe_scale(mid)
if complete:
lo = mid
else:
hi = mid
if best_wc is not None:
final_wc = best_wc
final_scale = best_scale
final_layout_seed = base_layout_seed
if final_wc is None:
return {
"wc": None,
"fill_ratio": 0.0,
"coverage": 0.0,
"occ_fast": None,
"w_small": w_small,
"h_small": h_small,
@@ -250,10 +308,16 @@ def run_generation_pass(
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png"))
# Probe larger whole-cloud layouts and keep the largest complete one. If
# an earlier batch was too large, search the discrete interval between the
# complete and failed scales instead of accepting an over-aggressive
# shrink. Every probe rebuilds the entire cloud with one shared scale.
# Probe whole-cloud layouts in BOTH font-size directions and keep the one
# whose ink reaches furthest into the mask shape. The original search only
# grew the font (chasing a higher pixel fill ratio), but a Fermat spiral
# packs words into a disc around the centroid: growing the font shrinks that
# disc, so an under-filled silhouette gets *more* circular, not less.
# Shrinking the font lets the spiral walk further out and reach the mask's
# protrusions, which raises shape coverage even when the raw fill ratio
# drops a little. Both directions are probed each round and the higher-
# coverage candidate wins; the loop stops when neither improves coverage.
# Every probe rebuilds the entire cloud with one shared scale.
if (
len(final_wc.layout_) >= total_target
and fill_ratio > 0
@@ -262,11 +326,22 @@ def run_generation_pass(
or has_character_sized_hole(final_wc, largest_empty_square)
)
):
current_coverage = compute_coverage_score(occ_fast, mask_small)
free_px = max(1, int(np.sum(mask_small == 0)))
upper_scale = min(
(failed for failed in failed_scales if failed > final_scale),
default=None,
)
for density_attempt in range(1, 5):
def _fill_and_coverage(wc):
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
new_fill = float(np.sum((mask_small == 0) & (occ == 1))) / free_px
cov = compute_coverage_score(occ, mask_small)
return new_fill, cov, occ
for density_attempt in range(1, 2 if config.FAST_MODE else 5):
# Grow direction (larger font): bisect toward a known-too-big scale,
# or nudge up by the fill-ratio deficit, exactly as before.
if upper_scale is not None:
grow_scale = (final_scale + upper_scale) / 2.0
elif equal_size_mode:
@@ -277,61 +352,79 @@ def run_generation_pass(
1.12,
math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98,
)
if desired_growth <= 1.005:
break
grow_scale = final_scale * desired_growth
grow_scale = final_scale * desired_growth if desired_growth > 1.005 else None
grow_bounds = scaled_bounds(grow_scale)
if grow_bounds == scaled_bounds(final_scale):
break
grow_min, grow_max = grow_bounds
layout_key = (grow_bounds, base_layout_seed)
attempted_layout = False
if layout_key not in tried_layouts:
tried_layouts.add(layout_key)
wc, placed_count, _, _ = try_place(grow_scale)
attempted_layout = True
print(
f" 密度优化 #{density_attempt}: scale={grow_scale:.3f}, "
f"字号=[{grow_min}, {grow_max}] -> {placed_count}/{total_target}"
)
else:
wc, placed_count = None, -1
# Shrink direction (smaller font): the inverse nudge. Letting the
# spiral walk further out costs font size but can reach protrusions
# the grow direction abandons. Cap the shrink so one round cannot
# collapse the font to the floor.
shrink_scale = None
if not equal_size_mode and fill_ratio > 0:
shrink_factor = 1.0 / max(1.02, min(1.20, math.sqrt(fill_ratio / max(0.05, config.TARGET_FILL_RATIO)) * 1.02))
cand = final_scale * shrink_factor
if scaled_bounds(cand) != scaled_bounds(final_scale):
shrink_scale = cand
elif equal_size_mode:
current_size, _ = scaled_bounds(final_scale)
if current_size > hard_min_font:
shrink_scale = (current_size - 1) / max(1, base_min_font)
selected_seed = base_layout_seed
if placed_count < total_target and base_layout_seed is not None:
# The reference library samples a fresh legal position order.
# One bounded whole-cloud re-layout recovers dense solutions
# without per-word shrinking or an unbounded random search.
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
retry_key = (grow_bounds, candidate_seed)
if retry_key not in tried_layouts:
tried_layouts.add(retry_key)
retry_wc, retry_count, _, _ = try_place(grow_scale, candidate_seed)
attempted_layout = True
candidates = []
for direction, scale in (("grow", grow_scale), ("shrink", shrink_scale)):
if scale is None or scaled_bounds(scale) == scaled_bounds(final_scale):
continue
bounds = scaled_bounds(scale)
d_min, d_max = bounds
key = (bounds, base_layout_seed)
wc, placed_count, selected_seed = None, -1, base_layout_seed
if key not in tried_layouts:
tried_layouts.add(key)
wc, placed_count, _, _ = try_place(scale)
# Bounded seed retry to recover a complete layout, as before.
if placed_count < total_target and base_layout_seed is not None:
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
retry_key = (bounds, candidate_seed)
if retry_key not in tried_layouts:
tried_layouts.add(retry_key)
retry_wc, retry_count, _, _ = try_place(scale, candidate_seed)
print(
f" 密度优化 #{density_attempt} {direction} 整批重排: "
f"seed={candidate_seed}, 字号=[{d_min}, {d_max}] -> "
f"{retry_count}/{total_target}"
)
if retry_count > placed_count:
wc, placed_count = retry_wc, retry_count
selected_seed = candidate_seed
if placed_count < total_target:
if direction == "grow":
upper_scale = scale
print(
f" 密度优化 #{density_attempt} 整批重排: "
f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> "
f"{retry_count}/{total_target}"
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} (不完整)"
)
if retry_count > placed_count:
wc = retry_wc
placed_count = retry_count
selected_seed = candidate_seed
if not attempted_layout:
break
if placed_count < total_target:
upper_scale = grow_scale
continue
continue
new_fill, cov, occ = _fill_and_coverage(wc)
print(
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} "
f"fill={new_fill:.3f} coverage={cov:.4f}"
)
candidates.append((direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ))
new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
if new_fill <= fill_ratio:
if not candidates:
break
# Pick the higher-coverage candidate; tie-break on fill ratio so a
# genuinely denser picture still wins when coverage is equal.
candidates.sort(key=lambda c: (c[6], c[5]))
direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ = candidates[-1]
if cov <= current_coverage and new_fill <= fill_ratio:
break
final_wc = wc
final_scale = grow_scale
final_scale = scale
final_layout_seed = selected_seed
fill_ratio = new_fill
occ_fast = new_occ
occ_fast = occ
current_coverage = cov
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
@@ -339,6 +432,7 @@ def run_generation_pass(
if (
fill_ratio >= config.TARGET_FILL_RATIO * 0.98
and not has_character_sized_hole(final_wc, largest_empty_square)
and current_coverage >= 0.98
):
break
@@ -350,6 +444,7 @@ def run_generation_pass(
and len(final_wc.layout_) >= total_target
and has_character_sized_hole(final_wc, largest_empty_square)
and base_layout_seed is not None
and not config.FAST_MODE
):
if total_target < 100:
hole_attempt_budget = 3
@@ -389,6 +484,77 @@ def run_generation_pass(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
# ── 工作网格增量填充:原名字号不变,用更小字号追加副本填轮廓 ──
# 已填区域标记为阻挡,新词只能进空白间隙。每一轮只生成一份名单,
# 并把新占用合并回工作网格;因此自动填充没有固定的重复数量,
# 只在轮廓仍未覆盖且还有合法位置时继续追加。
fill_work_layout = []
if (
config.AUTO_REPEAT_TO_FILL
and final_wc is not None
and len(final_wc.layout_) >= total_target
and occ_fast is not None
):
current_cov = compute_coverage_score(occ_fast, mask_small)
if current_cov < 0.95 and names:
# 原名字号范围
original_sizes = [size for _, size, *_ in final_wc.layout_]
orig_min_font = int(min(original_sizes))
orig_max_font = int(max(original_sizes))
span = orig_max_font - orig_min_font
# 追加用缩小字号:原最大字号的 50~60%
fill_min_font = max(config.MIN_FONT_FLOOR, int(round(orig_min_font * 0.50)))
fill_max_font = max(fill_min_font, int(round(orig_min_font + span * 0.60)))
fill_seed = (final_layout_seed ^ 0x9E3779B9) & 0x7FFFFFFF
if fill_seed == 0:
fill_seed = 1
# AUTO_REPEAT_MAX 只是防止异常掩膜导致无限循环,不是目标重复次数。
max_fill_rounds = max(1, int(config.AUTO_REPEAT_MAX))
for fill_round in range(max_fill_rounds):
if current_cov >= 0.95:
break
# 融合 mask:原阻挡 + 已填区域都标为 255
fill_mask = mask_small.copy()
fill_mask[(occ_fast == 1)] = 255
fill_wc = OptimizedEfficientWordCloud(
width=w_small, height=h_small,
mask=fill_mask,
font_path=config.WC_FONT_PATH,
# 一轮只追加一份名单;需要更多时由下一轮按需追加。
max_words=len(names),
min_font_size=fill_min_font,
max_font_size=fill_max_font,
background_color=config.get_output_background(),
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
margin=_collision_margin,
)
fill_wc.layout_seed = (fill_seed + fill_round) & 0x7FFFFFFF or 1
fill_freq = {name: 0.3 for name in names}
fill_wc.generate_from_frequencies(fill_freq)
fill_placed = len(fill_wc.layout_)
print(
f"[增量填充#{fill_round + 1}] 工作网格追加放置 "
f"{fill_placed}/{len(names)} 词,字号=[{fill_min_font}, {fill_max_font}]"
)
log.info(
"[增量填充#%d] 工作网格追加放置 %d/%d 词, 字号=[%d,%d]",
fill_round + 1, fill_placed, len(names), fill_min_font, fill_max_font,
)
if fill_placed <= 0:
break
fill_work_layout.extend(fill_wc.layout_)
fill_occ = render_layout_occupancy(
fill_wc.layout_, mask_small.shape, config.WC_FONT_PATH
)
occ_fast = np.maximum(occ_fast, fill_occ)
current_cov = compute_coverage_score(occ_fast, mask_small)
print(f"[增量填充#{fill_round + 1}] 工作网格覆盖度={current_cov:.4f}")
hd_layout = None
hd_clearance = None
raw_hd_layout = []
@@ -473,14 +639,73 @@ def run_generation_pass(
config.WC_FONT_PATH,
)
# ── 增量填充(工作网格 → HD) ──
# 工作网格上的合法位置经过放大后可能因取整发生碰撞,因此填充词必须
# 与基础布局一起再次做高清精修。若整批填充无法通过,则二分保留最多
# 的追加词;绝不能让自动填充破坏原本已经成功的基础布局。
if (
fill_work_layout
and hd_layout is not None
):
old_count = len(hd_layout)
fill_hd = scale_layout_for_hd(fill_work_layout, config.WORK_SCALE)
base_hd_layout = list(hd_layout)
accepted_additions, clearance_stats = append_layout_with_hd_clearance(
base_hd_layout,
fill_hd,
mask_hd,
config.WC_FONT_PATH,
clearance=0,
allow_global_search=False,
)
accepted_count = len(accepted_additions)
accepted_clearance = 0
hd_layout = base_hd_layout + accepted_additions
hd_overlap_pixels = count_layout_overlap_pixels(
hd_layout, (real_hd_h, real_hd_w), config.WC_FONT_PATH
)
new_fill, new_occ = compute_fill_ratio_fast(
hd_layout, mask_hd, config.WC_FONT_PATH
)
new_cov = compute_coverage_score(new_occ, mask_hd) if new_occ is not None else 0.0
clearance_stats = dict(clearance_stats or {})
clearance_stats["clearance_px"] = accepted_clearance
hd_clearance = clearance_stats
print(
f"[增量填充] 高清验收: {old_count}+{accepted_count}="
f"{len(hd_layout)} 词, fill={new_fill:.3f}, "
f"coverage={new_cov:.4f}, overlap={hd_overlap_pixels}"
)
print(
f"[增量填充] 工作网格候选 {len(fill_work_layout)} 词,"
f"高清接受 {len(hd_layout) - old_count}"
)
log.info(
"[增量填充] HD 验收: base=%d candidate=%d accepted=%d fill=%.4f coverage=%.4f overlap=%d",
old_count, len(fill_hd), len(hd_layout) - old_count, new_fill, new_cov, hd_overlap_pixels,
)
fill_ratio = new_fill
occ_fast = new_occ
largest_empty_square = largest_empty_square_size(occ_fast, mask_hd)
print(
f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | "
f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | "
f"隔离带: {hd_clearance['clearance_px']}px"
)
# occ_fast 可能是工作网格或 HD 网格形状(增量填充后),按形状匹配计算覆盖度
if occ_fast is not None and occ_fast.shape == mask_small.shape:
coverage = compute_coverage_score(occ_fast, mask_small)
elif occ_fast is not None and occ_fast.shape == mask_hd.shape:
coverage = compute_coverage_score(occ_fast, mask_hd)
else:
coverage = 0.0
print(f"轮廓覆盖度: {coverage:.4f}")
return {
"wc": final_wc,
"fill_ratio": fill_ratio,
"coverage": coverage,
"occ_fast": occ_fast,
"w_small": w_small,
"h_small": h_small,
@@ -614,6 +839,9 @@ def main():
)
frequencies_data = name_weights_map if config.REMOVE_DUPLICATES else [(name, name_weights_map.get(name, 10)) for name in names]
canvas_retry_round = 0
generation_result = None
t_gen = time.time()
canvas_retry_round = 0
generation_result = None
t_gen = time.time()
@@ -649,8 +877,9 @@ def main():
canvas_retry_round,
)
sys.exit(1)
log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d",
placed, target, generation_result["fill_ratio"], canvas_retry_round)
log.info(" 生成完成 placed=%d/%d fill=%.4f coverage=%.4f retry=%d",
placed, target, generation_result["fill_ratio"],
generation_result.get("coverage", 0.0), canvas_retry_round)
break
canvas_retry_round += 1
+285 -22
View File
@@ -3,6 +3,37 @@ from PIL import Image, ImageDraw, ImageFilter, ImageFont
from .fonts import get_cached_font
# (font_path, word, size, orient) -> (ink[h,w] uint8, bbox_left, bbox_top).
# The HD passes -- clearance refinement and the independent overlap audit --
# rasterize the same words at the same sizes, and rasterizing is the single
# most expensive thing either of them does, so they share one cache.
_HD_INK_CACHE = {}
def _word_ink(word, size, orient, font_path):
"""Return (ink array, bbox_left, bbox_top) for a word, or None if empty."""
key = (font_path, word, int(size), orient)
cached = _HD_INK_CACHE.get(key)
if cached is not None or key in _HD_INK_CACHE:
return cached
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = ImageDraw.Draw(Image.new("L", (1, 1))).textbbox((0, 0), word, font=font)
glyph = font.getmask(word, mode="L")
glyph_width, glyph_height = glyph.size
if glyph_width <= 0 or glyph_height <= 0:
result = None
else:
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
result = (ink, int(bbox[0]), int(bbox[1]))
_HD_INK_CACHE[key] = result
if len(_HD_INK_CACHE) > 20000:
for i, k in enumerate(list(_HD_INK_CACHE.keys())):
if i % 2 == 0:
_HD_INK_CACHE.pop(k, None)
return result
def scale_layout_for_hd(layout, work_scale):
if work_scale <= 0:
@@ -24,21 +55,17 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0
height, width = mask_shape
occupied = np.zeros((height, width), dtype=bool)
overlaps = np.zeros((height, width), dtype=bool)
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
for word, size, (y, x), orient, _color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = measure.textbbox((0, 0), word, font=font)
glyph = font.getmask(word, mode="L")
glyph_width, glyph_height = glyph.size
if glyph_width <= 0 or glyph_height <= 0:
measured = _word_ink(word, size, orient, font_path)
if measured is None:
continue
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold
ink_arr, bbox_left, bbox_top = measured
glyph_height, glyph_width = ink_arr.shape
ink = ink_arr > alpha_threshold
ink_x = int(x) + int(bbox[0])
ink_y = int(y) + int(bbox[1])
ink_x = int(x) + bbox_left
ink_y = int(y) + bbox_top
x0 = max(0, ink_x)
y0 = max(0, ink_y)
x1 = min(width, ink_x + glyph_width)
@@ -54,6 +81,56 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0
return int(overlaps.sum())
def _find_free_placement(blocked, occupied, collision, stamp, base_y, base_x):
"""Find any canvas position where `collision` hits nothing already taken.
Returns the (dy, dx) offset from (base_y, base_x), or None. Candidates are
ranked by distance from the original spot so a relocated word stays as close
to its intended position as possible.
A position whose whole footprint is empty is guaranteed to fit, so the
search first looks for those using an integral image, which rejects the vast
majority of positions with two additions instead of a per-pixel test.
"""
height, width = blocked.shape
gh, gw = collision.shape
if height - gh < 0 or width - gw < 0:
return None
# Search expanding windows around the intended spot instead of the whole
# canvas: a relocated word almost always finds room nearby, and the integral
# image costs time proportional to the area examined. The last radius covers
# the full canvas, so nothing is missed if the neighbourhood really is full.
for radius in (256, 1024, max(height, width)):
y_lo = max(0, base_y - radius)
x_lo = max(0, base_x - radius)
y_hi = min(height, base_y + radius + gh)
x_hi = min(width, base_x + radius + gw)
if y_hi - y_lo < gh or x_hi - x_lo < gw:
continue
taken = blocked[y_lo:y_hi, x_lo:x_hi] | occupied[y_lo:y_hi, x_lo:x_hi]
integral = np.pad(taken.astype(np.int32), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
# Footprint sum for every candidate top-left corner in the window. A
# position whose whole footprint is empty is guaranteed to fit, so no
# per-pixel mask test is needed.
counts = (
integral[gh:, gw:]
- integral[:-gh, gw:]
- integral[gh:, :-gw]
+ integral[:-gh, :-gw]
)
ys, xs = np.nonzero(counts == 0)
if ys.size == 0:
continue
dy = ys.astype(np.int64) + y_lo - base_y
dx = xs.astype(np.int64) + x_lo - base_x
best = int(np.argmin(dy * dy + dx * dx))
return int(dy[best]), int(dx[best])
return None
def refine_layout_with_hd_clearance(
layout,
mask,
@@ -65,7 +142,6 @@ def refine_layout_with_hd_clearance(
height, width = mask.shape
blocked = np.asarray(mask) != 0
occupied = np.zeros((height, width), dtype=bool)
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
refined = []
shifted_words = 0
max_applied_shift = 0
@@ -82,17 +158,13 @@ def refine_layout_with_hd_clearance(
offset_rings.append(ring)
for word, size, (draw_y, draw_x), orient, color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = measure.textbbox((0, 0), word, font=font)
glyph = font.getmask(word, mode="L")
glyph_width, glyph_height = glyph.size
if glyph_width <= 0 or glyph_height <= 0:
measured = _word_ink(word, size, orient, font_path)
if measured is None:
refined.append((word, size, (draw_y, draw_x), orient, color))
continue
glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
glyph_ink, bbox_left, bbox_top = measured
glyph_height, glyph_width = glyph_ink.shape
pad = max(0, int(clearance))
padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8)
padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink
@@ -105,8 +177,8 @@ def refine_layout_with_hd_clearance(
collision = padded > 0
stamp = padded > 0
base_y = int(draw_y) + int(bbox[1]) - pad
base_x = int(draw_x) + int(bbox[0]) - pad
base_y = int(draw_y) + bbox_top - pad
base_x = int(draw_x) + bbox_left - pad
def fits(y0, x0):
y1 = y0 + collision.shape[0]
@@ -129,6 +201,16 @@ def refine_layout_with_hd_clearance(
if placed_offset is not None:
break
if placed_offset is None:
# Nothing within max_shift. Rather than fail the batch -- which
# makes the caller rebuild the entire cloud on a larger canvas, by
# far the most expensive thing that can happen -- look for any free
# spot on the whole canvas. This only runs for the occasional word
# whose work-grid position does not survive the scale-up to HD.
placed_offset = _find_free_placement(
blocked, occupied, collision, stamp, base_y, base_x
)
if placed_offset is None:
return None, {
"shifted_words": shifted_words,
@@ -152,6 +234,126 @@ def refine_layout_with_hd_clearance(
}
def append_layout_with_hd_clearance(
base_layout,
additions,
mask,
font_path,
clearance=1,
max_shift=24,
allow_global_search=False,
):
"""Place only *additions* against an already validated HD layout.
The normal refinement pass must rebuild occupancy for every word because it
is allowed to move the whole batch. Auto-repeat words are appended after the
base batch has already passed refinement, so rescanning that batch is wasted
work. This helper seeds occupancy from the base once, then processes only
the new words and returns the largest collision-free prefix.
"""
height, width = mask.shape
blocked = np.asarray(mask) != 0
occupied = np.zeros((height, width), dtype=bool)
def stamp_existing(item):
word, size, (draw_y, draw_x), orient, _color = item
measured = _word_ink(word, size, orient, font_path)
if measured is None:
return
ink, bbox_left, bbox_top = measured
ink_y = int(draw_y) + bbox_top
ink_x = int(draw_x) + bbox_left
y0 = max(0, ink_y)
x0 = max(0, ink_x)
y1 = min(height, ink_y + ink.shape[0])
x1 = min(width, ink_x + ink.shape[1])
if y0 < y1 and x0 < x1:
occupied[y0:y1, x0:x1] |= ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x] > 0
for item in base_layout:
stamp_existing(item)
offset_rings = [[(0, 0)]]
for radius in range(1, max_shift + 1):
ring = [
(dy, dx)
for dy in range(-radius, radius + 1)
for dx in range(-radius, radius + 1)
if max(abs(dy), abs(dx)) == radius
]
ring.sort(key=lambda item: (item[0] * item[0] + item[1] * item[1], item[0], item[1]))
offset_rings.append(ring)
accepted = []
shifted_words = 0
max_applied_shift = 0
failed_word = None
for word, size, (draw_y, draw_x), orient, color in additions:
measured = _word_ink(word, size, orient, font_path)
if measured is None:
accepted.append((word, size, (draw_y, draw_x), orient, color))
continue
glyph_ink, bbox_left, bbox_top = measured
glyph_height, glyph_width = glyph_ink.shape
pad = max(0, int(clearance))
padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8)
padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink
if pad:
collision = np.asarray(
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
dtype=np.uint8,
) > 0
else:
collision = padded > 0
stamp = padded > 0
base_y = int(draw_y) + bbox_top - pad
base_x = int(draw_x) + bbox_left - pad
def fits(y0, x0):
y1 = y0 + collision.shape[0]
x1 = x0 + collision.shape[1]
if y0 < 0 or x0 < 0 or y1 > height or x1 > width:
return False
if np.any(blocked[y0:y1, x0:x1] & stamp):
return False
return not np.any(occupied[y0:y1, x0:x1] & collision)
placed_offset = None
for ring in offset_rings:
for dy, dx in ring:
if fits(base_y + dy, base_x + dx):
placed_offset = (dy, dx)
break
if placed_offset is not None:
break
if placed_offset is None and allow_global_search:
placed_offset = _find_free_placement(
blocked, occupied, collision, stamp, base_y, base_x
)
if placed_offset is None:
# A local-only append is deliberately best-effort: skipping one
# extra word is much cheaper than scanning the full HD canvas and
# keeps the latency predictable for large auto-repeat batches.
failed_word = word
continue
dy, dx = placed_offset
y0 = base_y + dy
x0 = base_x + dx
occupied[y0:y0 + stamp.shape[0], x0:x0 + stamp.shape[1]] |= stamp
if dy or dx:
shifted_words += 1
max_applied_shift = max(max_applied_shift, abs(dy), abs(dx))
accepted.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color))
return accepted, {
"shifted_words": shifted_words,
"max_shift": max_applied_shift,
"failed_word": failed_word,
}
def render_layout_occupancy(layout, mask_shape, font_path):
h, w = mask_shape
canvas = Image.new("L", (w, h), 0)
@@ -175,6 +377,67 @@ def compute_fill_ratio_fast(layout, mask, font_path):
return filled_area / free_area, occ
def compute_coverage_score(occupancy, mask, block_size=8):
"""Shape-aware fill quality: how broadly the ink reaches across the mask.
Unlike :func:`compute_fill_ratio_fast` (filled pixels / free pixels), this
rewards reaching *every part* of the mask silhouette -- protrusions, limb
tips, heart-shaped cusps -- that a centre-out Fermat spiral abandons first
when words are scarce or font sizes are large.
The fillable region is tiled into ``block_size`` cells. A cell counts as a
"region block" when at least 30% of its pixels are free; it is "covered"
when at least one of those free pixels is inked. The score is the share of
region blocks that are covered:
coverage = covered_blocks / region_blocks
A compact disc packed around the centroid touches only the central blocks,
so it scores low even at a high pixel fill ratio; a layout that spreads
into every arm of the mask touches blocks in each arm and scores high.
Block granularity (not per-pixel weighting) is what makes this robust to
the mask's geometry: a thin tip is one block whether it is 3px or 30px
wide, so reaching it is rewarded consistently. ``occupancy`` may be None
(treated as empty).
"""
mask_arr = np.asarray(mask)
if mask_arr.ndim != 2 or mask_arr.size == 0:
return 0.0
free = mask_arr == 0
if not free.any():
return 0.0
h, w = mask_arr.shape
occ = np.asarray(occupancy) if occupancy is not None else None
if occ is None or occ.shape != mask_arr.shape:
return 0.0
inked = (occ == 1) & free
# Block-aligned tile counts. Trailing partial blocks are merged into the
# last full block by clamping the end index, so no free pixels are dropped.
region_blocks = 0
covered_blocks = 0
for by in range(0, h, block_size):
y1 = min(h, by + block_size)
for bx in range(0, w, block_size):
x1 = min(w, bx + block_size)
block_free = free[by:y1, bx:x1]
free_count = int(block_free.sum())
if free_count == 0:
continue
# A block is a region if a meaningful share of it is fillable;
# this ignores blocks that only clip a mask corner.
if free_count < 0.30 * block_free.size:
continue
region_blocks += 1
if np.any(inked[by:y1, bx:x1] & block_free):
covered_blocks += 1
if region_blocks == 0:
return 0.0
return covered_blocks / region_blocks
def largest_empty_square_size(occupancy, mask):
"""Return the largest fully empty square inside the fillable mask."""
blocked = (np.asarray(mask) != 0) | (np.asarray(occupancy) != 0)
+483 -11
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import io
import json
import hashlib
import logging
import os
import re
@@ -10,6 +11,7 @@ import sqlite3
import threading
import time
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@@ -24,6 +26,7 @@ from core.fonts import get_cached_font
from .job_manager import JobManager
from .line_spacing import analyze_svg_line_spacing_file
from .log_config import get_logger
from .metadata_store import MetadataStore
from .runner import JobRunner
from .schemas import (
Asset,
@@ -57,12 +60,17 @@ ASSETS_DIR = PROJECT_ROOT / "service_assets"
PROJECTS_DIR = PROJECT_ROOT / "service_projects"
FONTS_DIR = PROJECT_ROOT / "service_fonts"
DESIGN_TEMPLATES_DIR = PROJECT_ROOT / "service_design_templates"
METADATA_DIR = PROJECT_ROOT / "service_metadata"
ORDERS_DIR = PROJECT_ROOT / "service_orders"
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
FONTS_DIR.mkdir(parents=True, exist_ok=True)
DESIGN_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
METADATA_DIR.mkdir(parents=True, exist_ok=True)
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
manager = JobManager()
metadata_store = MetadataStore(METADATA_DIR / "app.db")
manager = JobManager(metadata_store)
storage = Storage(WORKSPACE_DIR)
runner = JobRunner(PROJECT_ROOT, manager)
@@ -144,6 +152,31 @@ def health() -> dict:
return {"ok": True}
@app.get("/api/maintenance/storage-summary")
def storage_summary() -> dict:
referenced_jobs: set[str] = set()
for d in _list_dirs(ASSETS_DIR):
meta = _read_asset_meta(d)
job_id = meta.get("job_id") or ""
if job_id:
referenced_jobs.add(job_id)
stale = storage.stale_job_dirs(
referenced_job_ids=referenced_jobs,
exclude_job_ids=metadata_store.job_ids(),
max_age_days=0,
)
return {
"job_dir_count": len(storage.list_job_ids()),
"referenced_job_ids": len(referenced_jobs),
"stale_job_count": len(stale),
"reclaimable_bytes": sum(item["size_bytes"] for item in stale),
"metadata_db_bytes": metadata_store.summarize()["db_size_bytes"],
"jobs_in_db": metadata_store.summarize()["jobs"],
"events_in_db": metadata_store.summarize()["events"],
"dry_run_only": True,
}
@app.get("/api/jobs", response_model=list[JobStatus])
def list_jobs() -> list[JobStatus]:
with manager._lock:
@@ -153,26 +186,21 @@ def list_jobs() -> list[JobStatus]:
@app.post("/api/jobs", response_model=JobCreateResponse)
async def create_job(
mask_image: Optional[UploadFile] = File(None),
name_list: UploadFile = File(...),
name_list: Optional[UploadFile] = File(None),
wcd_file: Optional[UploadFile] = File(None),
font_file: Optional[UploadFile] = File(None),
font_id: str = Form(""),
params: str = Form("{}"),
) -> JobCreateResponse:
log.info("" * 50)
log.info("[API] POST /api/jobs 收到新任务请求")
log.info(" name_list.filename = %s", name_list.filename)
log.info(" name_list.filename = %s", name_list.filename if name_list else "")
log.info(" wcd_file.filename = %s", wcd_file.filename if wcd_file else "")
log.info(" mask_image.filename = %s", mask_image.filename if mask_image else "")
log.info(" font_file.filename = %s", font_file.filename if font_file else "")
log.info(" font_id = %s", font_id or "(未指定)")
log.info(" params (raw) = %s", params)
if not name_list.filename:
raise HTTPException(status_code=400, detail="name_list is required")
ext_xlsx = Path(name_list.filename).suffix.lower()
if ext_xlsx not in {".xlsx"}:
raise HTTPException(status_code=400, detail="name_list must be xlsx")
try:
user_params = json.loads(params)
except json.JSONDecodeError:
@@ -181,6 +209,23 @@ async def create_job(
if not isinstance(user_params, dict):
raise HTTPException(status_code=400, detail="params must be JSON object")
mode = str(user_params.get("MODE", "IMAGE")).upper()
# WCD 生产任务:传入 .wcd 画布导入导出包(还原设计 -> 生产,见 docs/wordcloud-contract.md v1.1
is_wcd = mode == "WCD" or bool(wcd_file and wcd_file.filename)
if is_wcd:
if not (wcd_file and wcd_file.filename):
raise HTTPException(status_code=400, detail="wcd_file is required when MODE=WCD")
return await _create_wcd_job(wcd_file, user_params)
# 名单/xlsx 模式(既有逻辑;必填校验移到 mode/WCD 判断之后)
if not name_list or not name_list.filename:
raise HTTPException(status_code=400, detail="name_list is required")
ext_xlsx = Path(name_list.filename).suffix.lower()
if ext_xlsx not in {".xlsx"}:
raise HTTPException(status_code=400, detail="name_list must be xlsx")
log.info(" 解析后 params = %s", json.dumps(user_params, ensure_ascii=False))
mode = str(user_params.get("MODE", "IMAGE")).upper()
@@ -234,8 +279,12 @@ async def create_job(
"MODE": mode,
"EXCEL_PATH": str(paths.excel_path),
"OUTPUT_DIR": str(paths.output_dir),
"SAVE_DEBUG_IMAGES": True,
# Debug masks are useful during local diagnosis but add extra image
# writes to every request. Keep the fast service path disk-light; the
# explicit CLI/config option remains available when debugging.
"SAVE_DEBUG_IMAGES": False,
"DEBUG_OUTPUT_DIR": str(paths.output_dir / "debug"),
"FAST_MODE": True,
}
if mask_image and mask_image.filename:
config["MASK_IMAGE_PATH"] = str(paths.mask_path)
@@ -273,6 +322,174 @@ async def create_job(
return JobCreateResponse(job_id=job_id)
def _parse_hex(color: object):
"""'#RRGGBB' / '#RGB' / 空值 解析为 RGBA tuple。"""
raw = str(color or "#ffffff").strip().lstrip("#")
if len(raw) == 3:
raw = "".join(c + c for c in raw)
try:
return tuple(int(raw[i : i + 2], 16) for i in (0, 2, 4)) + (255,)
except ValueError:
return (255, 255, 255, 255)
def _compose_design_png(document: dict, file_map: dict, output_path: Path) -> None:
"""把 CanvasDocument 合成一张扁平 PNG(生产任务产物):背景 + 按 zIndex 叠贴纸。
file_map: {pkg_asset_id: {"path": str}},即 WCD 内临时 assetId -> 落盘素材文件。
"""
try:
width = int(document.get("width") or 1200)
height = int(document.get("height") or 1200)
except (TypeError, ValueError):
width, height = 1200, 1200
canvas = Image.new("RGBA", (max(width, 1), max(height, 1)), _parse_hex(document.get("background")))
elements = [
e
for e in document.get("elements", [])
if isinstance(e, dict) and e.get("type") == "sticker"
]
elements.sort(key=lambda e: e.get("zIndex", 0))
for e in elements:
info = file_map.get(str(e.get("assetId") or ""))
if not info:
continue
path = info.get("path")
if not path or not Path(path).exists():
continue
try:
img = Image.open(path).convert("RGBA")
except Exception:
continue
ew = e.get("width")
eh = e.get("height")
ew = int(ew) if isinstance(ew, (int, float)) and ew > 0 else img.width
eh = int(eh) if isinstance(eh, (int, float)) and eh > 0 else img.height
if (ew, eh) != (img.width, img.height):
img = img.resize((int(ew), int(eh)))
opacity = e.get("opacity", 1)
if isinstance(opacity, (int, float)) and opacity >= 0 and opacity != 1:
img = img.copy()
img.putalpha(img.getchannel("A").point(lambda v: round(v * float(opacity))))
canvas.alpha_composite(img, (int(e.get("x") or 0), int(e.get("y") or 0)))
canvas.convert("RGB").save(output_path, "PNG")
async def _create_wcd_job(wcd_file: UploadFile, user_params: dict) -> JobCreateResponse:
"""WCD 生产任务:校验并还原 .wcd,落库设计,合成生产 PNG 作为任务产物。
与 POST /api/design-templates/import 共用素材去重/注册/重映射逻辑;
状态机与产物管线复用 jobsqueued/running/success/failed)。
"""
if Path(wcd_file.filename).suffix.lower() != ".wcd":
raise HTTPException(status_code=400, detail="wcd_file must be .wcd")
content = await wcd_file.read()
if not content:
raise HTTPException(status_code=400, detail="wcd_file is empty")
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc
with zf:
manifest = _read_import_json(zf, "manifest.json")
order_meta = manifest.get("meta") or {}
order_no = str(order_meta.get("orderNo") or "") if isinstance(order_meta, dict) else ""
# 兼容旧命名 order-{orderNo}
if not order_no and str(manifest.get("name") or "").startswith("order-"):
order_no = str(manifest.get("name"))[6:]
if manifest.get("format") != "wordcloud-canvas":
raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式")
try:
version = int(manifest.get("version", 1))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc
if version != 1:
raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}")
document = _read_import_json(zf, "document.json")
asset_items = manifest.get("assets")
if not isinstance(asset_items, list):
asset_items = []
remap: dict[str, str] = {}
file_map: dict[str, dict] = {}
reference_ids: list[str] = []
for item in asset_items:
pkg_id = str(item.get("id") or "")
if not pkg_id:
continue
entry_name = _zip_asset_entry(zf, pkg_id)
mime = _imported_asset_mime(item)
asset_bytes = zf.read(entry_name)
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
real_id = str(meta["asset_id"])
remap[pkg_id] = real_id
file_map[pkg_id] = {
"path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"),
"mime": mime,
}
reference_ids.append(real_id)
_remap_import_asset_ids(document, remap)
# 落库生产设计(与模板导入一致,便于追溯/复用/在画布中继续编辑)
display_name = str(manifest.get("name") or "导入生产设计").strip()[:120] or "导入生产设计"
template_id = f"tmpl_{uuid.uuid4().hex}"
now = datetime.now(timezone.utc).isoformat()
_write_design_template(_design_template_dir(template_id), {
"template_id": template_id,
"name": display_name,
"description": str(manifest.get("description") or "下单派单生产设计"),
"document": document,
"reference_asset_ids": reference_ids,
"cover_asset_id": reference_ids[0] if reference_ids else "",
"created_at": now,
"updated_at": now,
})
job_id = manager.create_job()
paths = storage.prepare_job_dirs(job_id)
(paths.input_dir / f"{template_id}.wcd").write_bytes(content)
log.info("[WCD] 生产任务 job_id=%s template=%s assets=%d", job_id, template_id, len(reference_ids))
# 登记生产订单(供 wordcloud 侧订单列表查看)
if order_no:
_write_order(_order_dir(order_no), {
"order_id": order_no,
"order_no": order_no,
"job_id": job_id,
"template_id": template_id,
"status": "running",
"created_at": datetime.now(timezone.utc).isoformat(),
})
log.info("[Order] 登记生产订单 %s → job %s", order_no, job_id)
manager.set_status(
job_id, status="running", stage="composing", progress_percent=10, message="WCD 生产任务"
)
def _run_wcd_safe() -> None:
try:
png_path = paths.output_dir / "result.png"
_compose_design_png(document, file_map, png_path)
manager.set_artifacts(job_id, {"png": str(png_path)})
manager.set_status(
job_id, status="success", stage="done", progress_percent=100, message="生产完成"
)
except Exception as exc:
logging.exception("wcd job crashed", extra={"job_id": job_id})
manager.set_status(
job_id,
status="failed",
stage="failed",
progress_percent=100,
message="任务失败",
error=str(exc),
)
threading.Thread(target=_run_wcd_safe, daemon=True).start()
return JobCreateResponse(job_id=job_id)
@app.get("/api/jobs/{job_id}", response_model=JobStatus)
def get_job(job_id: str) -> JobStatus:
if not manager.exists(job_id):
@@ -329,6 +546,7 @@ def get_result(job_id: str) -> JobResult:
svg_stroke_url=u("svg_stroke"),
db_url=u("db"),
metrics_url=u("metrics"),
elapsed_seconds=status.elapsed_seconds,
)
@@ -676,6 +894,45 @@ def get_file(job_id: str, kind: str):
return FileResponse(path, media_type=media, filename=path.name)
# ═══════════════════════════════════════════════════════════
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务)
# ═══════════════════════════════════════════════════════════
@app.get("/api/orders")
def list_orders() -> list[dict]:
"""生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务。"""
orders = []
if ORDERS_DIR.exists():
for item in list(ORDERS_DIR.iterdir()):
if not item.is_dir() or not (item / "order.json").exists():
continue
o = _read_order(item)
if not o:
continue
# 用 job 的最新状态回填
try:
st = manager.get_status(str(o.get("job_id") or ""))
o["status"] = st.status
except Exception:
pass
orders.append(o)
return list(reversed(orders))
@app.get("/api/orders/{order_no}")
def get_order(order_no: str) -> dict:
path = Path(_order_dir(order_no)) / "order.json"
if not path.exists():
raise HTTPException(status_code=404, detail="order not found")
o = json.loads(path.read_text(encoding="utf-8"))
try:
st = manager.get_status(str(o.get("job_id") or ""))
o["status"] = st.status
except Exception:
pass
return o
# ═══════════════════════════════════════════════════════════
# 4. 模板接口
# ═══════════════════════════════════════════════════════════
@@ -700,6 +957,26 @@ def _write_design_template(template_dir: Path, data: dict) -> None:
)
# ── 生产订单存储(下单派单投递的 WCD 生产任务)────────────
def _order_dir(order_no: str) -> Path:
return ORDERS_DIR / order_no
def _read_order(order_dir: Path) -> dict:
path = order_dir / "order.json"
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def _write_order(order_dir: Path, data: dict) -> None:
order_dir.mkdir(parents=True, exist_ok=True)
(order_dir / "order.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _parse_json_array(value: str, field_name: str) -> list:
try:
parsed = json.loads(value or "[]")
@@ -758,6 +1035,201 @@ async def create_design_template(
return DesignTemplate(**data)
def _read_import_json(zf: zipfile.ZipFile, name: str) -> dict:
try:
with zf.open(name) as fh:
raw = fh.read().decode("utf-8")
except KeyError as exc:
raise HTTPException(status_code=400, detail=f"{name} 缺失") from exc
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"{name} 不是合法 JSON") from exc
if not isinstance(data, dict):
raise HTTPException(status_code=400, detail=f"{name} 必须是 JSON 对象")
return data
def _imported_asset_mime(item: dict) -> str:
mime = str(item.get("mimeType") or item.get("mime_type") or "").strip().lower()
if mime in {"image/svg+xml", "image/png", "image/jpeg"}:
return mime
asset_type = str(item.get("type") or "").strip().lower()
if asset_type in {"svg", "image/svg+xml"}:
return "image/svg+xml"
if asset_type in {"image", "png"}:
return "image/png"
return "image/svg+xml" if str(item.get("id", "")).endswith(".svg") else "image/png"
def _asset_meta_items() -> list[dict]:
result: list[dict] = []
for d in _list_dirs(ASSETS_DIR):
meta = _read_asset_meta(d)
if meta:
result.append(meta)
return result
def _find_asset_by_sha256(digest: str) -> dict | None:
for meta in _asset_meta_items():
if meta.get("sha256") == digest:
return meta
for meta in _asset_meta_items():
try:
ext = _asset_extension_for_mime(str(meta.get("mime_type") or ""))
path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}"
if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
meta["sha256"] = digest
_write_asset_meta(_asset_dir(str(meta["asset_id"])), meta)
return meta
except Exception:
continue
return None
def _register_import_asset(name: str, content: bytes, mime: str) -> dict:
digest = hashlib.sha256(content).hexdigest()
existing = _find_asset_by_sha256(digest)
if existing:
return existing
asset_id = f"asset_{uuid.uuid4().hex}"
asset_path = _asset_dir(asset_id)
asset_path.mkdir(parents=True, exist_ok=True)
ext = _asset_extension_for_mime(mime)
dest = asset_path / f"asset{ext}"
dest.write_bytes(content)
width, height = 0, 0
if mime == "image/svg+xml":
width, height = _parse_svg_viewbox(dest)
else:
try:
with Image.open(dest) as img:
width, height = img.size
except Exception:
pass
meta = {
"asset_id": asset_id,
"name": name[:120] or "导入素材",
"type": "sticker",
"mime_type": mime,
"width": width,
"height": height,
"file_size": len(content),
"sha256": digest,
"file_url": f"/api/assets/{asset_id}/download",
"job_id": "",
"created_at": datetime.now(timezone.utc).isoformat(),
}
_write_asset_meta(asset_path, meta)
return meta
def _zip_asset_entry(zf: zipfile.ZipFile, pkg_id: str) -> str:
candidates: list[str] = []
for entry in zf.namelist():
parts = entry.split("/")
if (
len(parts) >= 2
and parts[0] == "assets"
and parts[1].startswith(pkg_id)
and ".." not in parts
and not entry.endswith("/")
):
candidates.append(entry)
if not candidates:
raise HTTPException(status_code=400, detail=f"包内缺少素材: {pkg_id}")
for entry in candidates:
if Path(entry).stem == pkg_id:
return entry
return sorted(candidates)[0]
def _remap_import_asset_ids(document: dict, asset_map: dict[str, str]) -> None:
elements = document.get("elements")
if not isinstance(elements, list):
return
for element in elements:
if isinstance(element, dict) and element.get("type") == "sticker":
old_id = element.get("assetId")
if isinstance(old_id, str) and old_id in asset_map:
element["assetId"] = asset_map[old_id]
@app.post("/api/design-templates/import", response_model=DesignTemplate)
async def import_design_template(
file: UploadFile = File(...),
name: str = Form(""),
description: str = Form(""),
) -> DesignTemplate:
if not file.filename:
raise HTTPException(status_code=400, detail="file is required")
if Path(file.filename).suffix.lower() != ".wcd":
raise HTTPException(status_code=400, detail="file must be .wcd")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="file is empty")
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc
with zf:
manifest = _read_import_json(zf, "manifest.json")
if manifest.get("format") != "wordcloud-canvas":
raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式")
try:
version = int(manifest.get("version", 1))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc
if version != 1:
raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}")
document = _read_import_json(zf, "document.json")
asset_items = manifest.get("assets")
if not isinstance(asset_items, list):
asset_items = []
asset_map: dict[str, str] = {}
reference_ids: list[str] = []
for item in asset_items:
if not isinstance(item, dict):
continue
pkg_id = str(item.get("id") or "")
if not pkg_id:
continue
entry_name = _zip_asset_entry(zf, pkg_id)
mime = _imported_asset_mime(item)
asset_bytes = zf.read(entry_name)
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
asset_map[pkg_id] = str(meta["asset_id"])
reference_ids.append(str(meta["asset_id"]))
_remap_import_asset_ids(document, asset_map)
display_name = name.strip() or str(manifest.get("name") or "导入设计").strip() or "导入设计"
display_description = description.strip() or str(manifest.get("description") or "").strip()
now = datetime.now(timezone.utc).isoformat()
template_id = f"tmpl_{uuid.uuid4().hex}"
data = {
"template_id": template_id,
"name": display_name[:120],
"description": display_description,
"document": document,
"reference_asset_ids": reference_ids,
"cover_asset_id": reference_ids[0] if reference_ids else "",
"created_at": now,
"updated_at": now,
}
_write_design_template(_design_template_dir(template_id), data)
return DesignTemplate(**data)
@app.get("/api/design-templates/{template_id}", response_model=DesignTemplate)
def get_design_template(template_id: str) -> DesignTemplate:
data = _read_design_template(_design_template_dir(template_id))
+54 -3
View File
@@ -6,7 +6,9 @@ import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from .metadata_store import MetadataStore
from .schemas import JobDetail, JobEvent, JobStatus
@@ -18,9 +20,20 @@ class JobState:
class JobManager:
def __init__(self) -> None:
def __init__(self, store: Optional[MetadataStore] = None) -> None:
self._lock = threading.Lock()
self.store = store
self._jobs: dict[str, JobState] = {}
self._restore_from_store()
def _restore_from_store(self) -> None:
if not self.store:
return
for status in self.store.load_jobs():
self._jobs[status.job_id] = JobState(
status=status,
events=self.store.load_events(status.job_id, limit=100),
)
def create_job(self) -> str:
job_id = uuid.uuid4().hex
@@ -38,6 +51,8 @@ class JobManager:
)
with self._lock:
self._jobs[job_id] = JobState(status=status)
if self.store:
self.store.upsert_job(status)
return job_id
def exists(self, job_id: str) -> bool:
@@ -53,11 +68,19 @@ class JobManager:
state = self._jobs[job_id]
return JobDetail(status=state.status, recent_events=state.events[-100:])
def delete_job(self, job_id: str) -> None:
with self._lock:
self._jobs.pop(job_id, None)
if self.store:
self.store.delete_job(job_id)
def set_artifacts(self, job_id: str, artifacts: dict[str, str]) -> None:
with self._lock:
status = self._jobs[job_id].status
status.artifacts = artifacts
status.updated_at = datetime.now(timezone.utc)
if self.store:
self.store.upsert_job(status)
def set_status(
self,
@@ -68,6 +91,7 @@ class JobManager:
progress_percent: int,
message: str,
error: str | None = None,
elapsed_seconds: float | None = None,
) -> None:
with self._lock:
s = self._jobs[job_id].status
@@ -78,14 +102,28 @@ class JobManager:
s.updated_at = datetime.now(timezone.utc)
if error is not None:
s.error = error
if elapsed_seconds is not None:
s.elapsed_seconds = elapsed_seconds
if self.store:
self.store.upsert_job(s)
def add_event(self, job_id: str, *, kind: str, stage: str, progress_percent: int, message: str) -> None:
def add_event(
self,
job_id: str,
*,
kind: str,
stage: str,
progress_percent: int,
message: str,
elapsed_seconds: float | None = None,
) -> None:
event = JobEvent(
type=kind,
stage=stage,
progress_percent=progress_percent,
message=message,
timestamp=datetime.now(timezone.utc),
elapsed_seconds=elapsed_seconds,
)
with self._lock:
state = self._jobs[job_id]
@@ -94,13 +132,26 @@ class JobManager:
state.status.progress_percent = progress_percent
state.status.message = message
state.status.updated_at = event.timestamp
if elapsed_seconds is not None:
state.status.elapsed_seconds = elapsed_seconds
for sub in state.subscribers:
sub.put(event)
if self.store:
self.store.add_event(job_id, event)
self.store.upsert_job(state.status)
def subscribe(self, job_id: str) -> queue.Queue:
q: queue.Queue = queue.Queue()
with self._lock:
self._jobs[job_id].subscribers.append(q)
state = self._jobs[job_id]
# A fast job can emit preview_ready/completed before the browser
# finishes opening the SSE connection. Replay the existing event
# history into this subscriber so timing and preview updates are
# never lost; the lock also makes the snapshot/registration
# atomic with respect to new events.
for event in state.events:
q.put(event)
state.subscribers.append(q)
return q
def unsubscribe(self, job_id: str, q: queue.Queue) -> None:
+192
View File
@@ -0,0 +1,192 @@
"""Small SQLite-backed metadata store for jobs and events.
This is intentionally dependency-free and acts as the first durable layer for
business metadata. The schema is shaped so it can be moved to PostgreSQL later
without changing callers.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Optional
from .schemas import JobEvent, JobStatus
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class MetadataStore:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _init_db(self) -> None:
with self._lock, self._connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
stage TEXT NOT NULL,
progress_percent INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
artifacts TEXT NOT NULL DEFAULT '{}',
error TEXT NOT NULL DEFAULT '',
elapsed_seconds REAL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
type TEXT NOT NULL,
stage TEXT NOT NULL,
progress_percent INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
timestamp TEXT NOT NULL,
elapsed_seconds REAL
);
CREATE INDEX IF NOT EXISTS idx_job_events_job_timestamp
ON job_events(job_id, id);
"""
)
def upsert_job(self, status: JobStatus) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO jobs (
id, status, stage, progress_percent, message,
artifacts, error, elapsed_seconds, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
stage = excluded.stage,
progress_percent = excluded.progress_percent,
message = excluded.message,
artifacts = excluded.artifacts,
error = excluded.error,
elapsed_seconds = excluded.elapsed_seconds,
updated_at = excluded.updated_at
""",
(
status.job_id,
status.status,
status.stage,
status.progress_percent,
status.message,
json.dumps(status.artifacts, ensure_ascii=False),
status.error,
status.elapsed_seconds,
status.created_at.isoformat(),
status.updated_at.isoformat(),
),
)
def add_event(self, job_id: str, event: JobEvent) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO job_events (
job_id, type, stage, progress_percent, message, timestamp, elapsed_seconds
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
job_id,
event.type,
event.stage,
event.progress_percent,
event.message,
event.timestamp.isoformat(),
event.elapsed_seconds,
),
)
def load_jobs(self) -> list[JobStatus]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT * FROM jobs
"""
).fetchall()
result: list[JobStatus] = []
for row in rows:
try:
result.append(
JobStatus(
job_id=row["id"],
status=row["status"],
stage=row["stage"],
progress_percent=row["progress_percent"],
message=row["message"],
artifacts=json.loads(row["artifacts"] or "{}"),
error=row["error"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
elapsed_seconds=row["elapsed_seconds"],
)
)
except Exception:
continue
return result
def load_events(self, job_id: str, limit: int = 100) -> list[JobEvent]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT type, stage, progress_percent, message, timestamp, elapsed_seconds
FROM job_events
WHERE job_id = ?
ORDER BY id DESC
LIMIT ?
""",
(job_id, limit),
).fetchall()
return [
JobEvent(
type=row["type"],
stage=row["stage"],
progress_percent=row["progress_percent"],
message=row["message"],
timestamp=datetime.fromisoformat(row["timestamp"]),
elapsed_seconds=row["elapsed_seconds"],
)
for row in reversed(rows)
]
def delete_job(self, job_id: str) -> None:
with self._lock, self._connect() as conn:
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
conn.execute("DELETE FROM job_events WHERE job_id = ?", (job_id,))
def job_ids(self) -> set[str]:
with self._lock, self._connect() as conn:
rows = conn.execute("SELECT id FROM jobs").fetchall()
return {row["id"] for row in rows}
def summarize(self) -> dict:
with self._lock, self._connect() as conn:
jobs = conn.execute("SELECT COUNT(*) AS n FROM jobs").fetchone()
events = conn.execute("SELECT COUNT(*) AS n FROM job_events").fetchone()
return {
"db_size_bytes": self.db_path.stat().st_size if self.db_path.exists() else 0,
"jobs": jobs["n"] if jobs else 0,
"events": events["n"] if events else 0,
}
+61 -6
View File
@@ -45,7 +45,7 @@ class JobRunner:
return current_stage, current_progress
def run(self, job_id: str, paths: JobPaths, config: dict) -> None:
t_start = time.time()
t_start = time.perf_counter()
log.info("=" * 50)
log.info("[Runner] 任务启动 job_id=%s", job_id)
log.info(" config_path = %s", paths.config_path)
@@ -65,6 +65,11 @@ class JobRunner:
]
env = os.environ.copy()
# Force unbuffered stdout so every print() flushes immediately and the
# frontend SSE log view shows each step in real time. Without this,
# Python block-buffers stdout when it is a pipe, so lines pile up and
# only arrive in bursts after the buffer fills or the process exits.
env["PYTHONUNBUFFERED"] = "1"
process = subprocess.Popen(
cmd,
cwd=str(self.project_root),
@@ -77,24 +82,63 @@ class JobRunner:
stage = "starting"
progress = 1
output_lines: list[str] = [] # collect all output lines for error reporting
preview_sent = False
assert process.stdout is not None
for raw in process.stdout:
line = raw.rstrip("\n")
output_lines.append(line)
log.info("[Pipeline] %s", line)
stage, progress = self._parse_stage(line, stage, progress)
elapsed = time.perf_counter() - t_start
self.manager.add_event(
job_id,
kind="log",
stage=stage,
progress_percent=progress,
message=line,
elapsed_seconds=round(elapsed, 3),
)
# The PNG is complete before SVG/DB export starts. Publish it as a
# preview so the frontend does not wait for the slower artifacts.
if not preview_sent and line.startswith("已保存:"):
candidate = Path(line.split(":", 1)[1].strip())
if candidate.suffix.lower() == ".png" and candidate.exists():
self.manager.set_artifacts(job_id, {"png": str(candidate)})
self.manager.add_event(
job_id,
kind="status",
stage="preview_ready",
progress_percent=max(progress, 94),
message="预览已生成,后台继续导出其余文件",
elapsed_seconds=round(elapsed, 3),
)
preview_sent = True
ret = process.wait()
elapsed = time.time() - t_start
elapsed = time.perf_counter() - t_start
log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed)
# ── 错误时:截取最后 30 行输出作为详细错误信息 ─────────────
error_detail = None
if ret != 0:
# 找到 "生成失败" 或 "错误" 或 traceback 之后的内容
error_lines = []
captured = False
for line in reversed(output_lines):
if not captured:
error_lines.append(line)
if any(kw in line for kw in ("生成失败", "error", "Error", "Traceback", "traceback", "未满足", "放置")):
captured = True
elif len(error_lines) < 30:
error_lines.append(line)
else:
break
error_lines.reverse()
error_detail = "\n".join(error_lines) if error_lines else f"script exited with code {ret}"
log.info("[Runner] 错误详情:\n%s", error_detail)
png = next(paths.output_dir.glob("*.png"), None)
# NOTE: do NOT use "*[!_stroke].svg" — in glob, [!...] is a character class,
# so filenames ending with "e.svg" (e.g. AutoResize.svg) are incorrectly skipped.
@@ -142,15 +186,24 @@ class JobRunner:
return
if ret == 0:
done_message = f"任务完成,用时 {elapsed:.2f}"
log.info("[Runner] ✅ 任务完成 job_id=%s 总耗时=%.2fs", job_id, elapsed)
self.manager.add_event(
job_id,
kind="status",
stage="completed",
progress_percent=100,
message="任务完成",
message=done_message,
elapsed_seconds=round(elapsed, 3),
)
self.manager.set_status(
job_id,
status="success",
stage="completed",
progress_percent=100,
message=done_message,
elapsed_seconds=round(elapsed, 3),
)
self.manager.set_status(job_id, status="success", stage="completed", progress_percent=100, message="任务完成")
else:
log.error("[Runner] ❌ 任务失败 job_id=%s exit_code=%d 耗时=%.2fs", job_id, ret, elapsed)
self.manager.add_event(
@@ -158,7 +211,8 @@ class JobRunner:
kind="status",
stage="failed",
progress_percent=100,
message=f"任务失败,退出码: {ret}",
message=f"任务失败,退出码: {ret},用时 {elapsed:.2f}",
elapsed_seconds=round(elapsed, 3),
)
self.manager.set_status(
job_id,
@@ -166,5 +220,6 @@ class JobRunner:
stage="failed",
progress_percent=100,
message="任务失败",
error=f"script exited with code {ret}",
error=error_detail or f"script exited with code {ret}",
elapsed_seconds=round(elapsed, 3),
)
+3
View File
@@ -17,6 +17,7 @@ class JobEvent(BaseModel):
progress_percent: int = Field(ge=0, le=100)
message: str
timestamp: datetime
elapsed_seconds: float | None = None
class JobStatus(BaseModel):
@@ -29,6 +30,7 @@ class JobStatus(BaseModel):
updated_at: datetime
artifacts: dict[str, str]
error: str = ""
elapsed_seconds: float | None = None
class JobDetail(BaseModel):
@@ -44,6 +46,7 @@ class JobResult(BaseModel):
svg_stroke_url: str = ""
db_url: str = ""
metrics_url: str = ""
elapsed_seconds: float | None = None
class WordLocation(BaseModel):
+74
View File
@@ -1,10 +1,17 @@
from __future__ import annotations
import os
import re
import shutil
import time
from pathlib import Path
from .schemas import JobPaths
_JOB_ID_RE = re.compile(r"^[0-9a-f]{32,}$")
class Storage:
def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir
@@ -25,3 +32,70 @@ class Storage:
excel_path=input_dir / "names.xlsx",
config_path=root / "config.json",
)
def job_root(self, job_id: str) -> Path:
return self.base_dir / job_id
def job_dir_size(self, job_id: str) -> int:
root = self.job_root(job_id)
if not root.exists():
return 0
total = 0
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
try:
total += os.path.getsize(os.path.join(dirpath, filename))
except OSError:
continue
return total
def job_dir_info(self, job_id: str) -> dict | None:
root = self.job_root(job_id)
if not root.is_dir():
return None
try:
mtime = root.stat().st_mtime
except OSError:
return None
return {
"job_id": job_id,
"path": str(root),
"size_bytes": self.job_dir_size(job_id),
"age_days": round(max(0.0, time.time() - mtime) / 86400.0, 2),
}
def list_job_ids(self) -> list[str]:
if not self.base_dir.exists():
return []
return [
item.name
for item in self.base_dir.iterdir()
if item.is_dir() and _JOB_ID_RE.match(item.name)
]
def stale_job_dirs(
self,
referenced_job_ids: set[str],
exclude_job_ids: set[str] | None = None,
max_age_days: float | None = None,
) -> list[dict]:
exclude = exclude_job_ids or set()
result: list[dict] = []
for job_id in self.list_job_ids():
if job_id in referenced_job_ids or job_id in exclude:
continue
info = self.job_dir_info(job_id)
if not info:
continue
if max_age_days is not None and info["age_days"] < max_age_days:
continue
result.append(info)
return sorted(result, key=lambda item: item["size_bytes"], reverse=True)
def remove_job_dir(self, job_id: str) -> int:
root = self.job_root(job_id)
if not root.exists():
return 0
size = self.job_dir_size(job_id)
shutil.rmtree(root, ignore_errors=True)
return size
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Inspect and optionally clean stale job directories.
Default mode is a safe dry-run that reports reclaimable bytes. Pass --apply to
actually remove unreferenced job directories.
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from .metadata_store import MetadataStore
from .storage import Storage
BACKEND_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = BACKEND_ROOT
WORKSPACE_DIR = PROJECT_ROOT / "service_workspace"
ASSETS_DIR = PROJECT_ROOT / "service_assets"
METADATA_DIR = PROJECT_ROOT / "service_metadata"
def read_asset_meta(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def referenced_job_ids() -> set[str]:
refs: set[str] = set()
for meta_path in ASSETS_DIR.glob("*/*/meta.json"):
job_id = read_asset_meta(meta_path).get("job_id") or ""
if job_id:
refs.add(job_id)
return refs
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--max-age-days", type=float, default=0)
parser.add_argument("--apply", action="store_true", help="Actually delete stale job directories")
parser.add_argument("--json", type=Path, default=None, help="Write JSON report")
args = parser.parse_args()
store = MetadataStore(METADATA_DIR / "app.db")
storage = Storage(WORKSPACE_DIR)
known_job_ids = store.job_ids() if store.db_path.exists() else set()
referenced = referenced_job_ids()
stale = storage.stale_job_dirs(
referenced_job_ids=referenced,
exclude_job_ids=known_job_ids,
max_age_days=args.max_age_days,
)
reclaimable = sum(item["size_bytes"] for item in stale)
report = {
"scanned_at": datetime.now(timezone.utc).isoformat(),
"job_dir_count": len(storage.list_job_ids()),
"referenced_job_ids": len(referenced),
"known_job_ids": len(known_job_ids),
"stale_job_count": len(stale),
"reclaimable_bytes": reclaimable,
"max_age_days": args.max_age_days,
"apply": args.apply,
"stale_jobs": stale[:200],
}
print(json.dumps(report, ensure_ascii=False, indent=2))
if args.json:
args.json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
if args.apply:
freed = 0
for item in stale:
freed += storage.remove_job_dir(item["job_id"])
store.delete_job(item["job_id"])
print(f"freed_bytes={freed}")
if __name__ == "__main__":
main()
+10
View File
@@ -53,6 +53,8 @@ class LayoutConstraintTests(unittest.TestCase):
"SEED",
"TARGET_FILL_RATIO",
"WC_FONT_PATH",
"AUTO_REPEAT_TO_FILL",
"AUTO_REPEAT_MAX",
)
}
config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT)
@@ -66,6 +68,7 @@ class LayoutConstraintTests(unittest.TestCase):
config.LAYOUT_SEED = 20260718
config.SEED = 20260718
config.TARGET_FILL_RATIO = 0.42
config.AUTO_REPEAT_TO_FILL = False
def tearDown(self) -> None:
for key, value in self.saved.items():
@@ -91,6 +94,13 @@ class LayoutConstraintTests(unittest.TestCase):
self.assertEqual(len(layout), len(names))
self.assertEqual(len({font_size for _, font_size, *_ in layout}), 1)
def test_auto_repeat_keeps_hd_layout_overlap_free(self) -> None:
config.AUTO_REPEAT_TO_FILL = True
config.AUTO_REPEAT_MAX = 2
names, result = self.generate(40)
self.assertGreaterEqual(len(result["hd_layout"]), len(names))
self.assertEqual(result["hd_overlap_pixels"], 0)
def test_explicit_equal_min_max_is_exact(self) -> None:
config.USER_MIN_FONT_SIZE = 12
config.USER_MAX_FONT_SIZE = 12
+1
View File
@@ -185,6 +185,7 @@ def main() -> None:
config.SEED = 20260718
config.TARGET_FILL_RATIO = 0.45
config.FONT_COLOR = "#102A43"
config.AUTO_REPEAT_TO_FILL = False
results = [
run_case(count, args.canvas, args.output_dir, args.max_growth_rounds)
+92 -15
View File
@@ -12,7 +12,7 @@
5. 估算字号范围 → weights.calculate_font_by_area_model()
6. 小画布布局 → layout.OptimizedEfficientWordCloud.generate_from_frequencies()
7. C++ 按真实字形找位置并原子写入 → IntegralGrid.place_glyph_exact()
8. 整批未完整放入 → 统一缩放字号或扩大画布后重排
8. 二分搜索能容纳全部姓名的最大字号缩放;仍放不下则扩大画布后重排
9. 密度优化 → 探测更大字号并保留完整率不下降的候选
10. 高清精修 → render.refine_layout_with_hd_clearance() 加入隔离带局部微调
11. 输出 PNG / SVG / DB / metrics
@@ -93,10 +93,15 @@ total_target = len(names) * N_REPETITIONS
## 放置策略
放置顺序按字号分档:大字号先随机撒开,其余再从中心螺旋填充。
大字号需要整片空白才放得下,等螺旋填满画布就没有空间了,因此必须先放。
档内按索引顺序放置,保证同一 `layout_seed` 完全可复现。
每个词的放置流程:
1. 根据目标分数得到目标字号(线性插值于 `min_font``max_font`
2. 随机决定横排或竖排`prefer_horizontal` 控制概率)
2. 逐词独立随机决定横排或竖排,竖排概率为 `VERTICAL_RATIO`(流水线以此换算 `prefer_horizontal = 1 - VERTICAL_RATIO`);
当前竖排为整词旋转 90°(字符侧倒),不是字符直立的传统竖排
3. 用 PIL 渲染真实字形 bitmap,施加单侧安全边距(margin)生成碰撞 mask
4. 调用 C++ `place_glyph_exact()` 搜索合法位置并原子写入
5. 当前方向找不到时,只尝试同字号的另一方向
@@ -113,14 +118,33 @@ total_target = len(names) * N_REPETITIONS
- `canvas`:真实占用像素(`1` 表示已占用或掩膜阻挡)
- `data`:兼容旧矩形查询的积分图;当前主路径不依赖逐词重建
`place_glyph_exact()` 行为
`place_glyph_exact()` 有三种放置模式,由 `layout.py` 按字号分配
1. 等字号批次前 `70%` 姓名优先选择靠近可填区域质心的合法位置,避免少量姓名接受第一个随机空位而形成大块空洞
2. 多字号批次只对`25%` 以及高权重(`score ≥ 0.80`)姓名启用中心偏好
3. 少于 `100` 人的等字号批次中心候选数提高到 `768``100300` 人提高到 `512`;其余保持 `256`
4. 随机探测失败后,从种子决定的偏移开始完整扫描
5. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
6. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
**大字号(`placement_mode=2`**:字号达到 `min_font + (max_font - min_font) * 0.80` 姓名先放,随机探测取第一个合法位置。
探测受一个软半径约束:`75%` 次探测限制在质心周围 `0.55 → 1.0` 倍掩膜半径内,之后放开。
约束只影响落点偏好,不会排除任何合法位置,因此不影响完整率。
等字号批次中 `max_font == min_font`,不存在大字号档,全部走螺旋。
**小字号(`placement_mode=1`**:先试可填区域质心,再沿费马螺旋(黄金角 `2.39996``radius = 1.25 * sqrt(step)`)向外搜索。
`spiral_cursor` 在整批姓名间持续推进。每个词以随机相位 `theta_offset ∈ [0, 2π)` 起扫,
使相邻两词的角距不再恒为黄金角;随机量取自按 `layout_seed` 派生的逐词种子,同一种子完全可复现,
不同种子给出真正不同的排布,而不只是同一图形换名字。
相位必须取满整圈。曾尝试限制在 `±60°` 的窄扇区内,结果明显更差:
扇区会让词跳过边界上已经合法的位置而退到更差的位置,实测 800 词的最终墨水密度下降 31%(`0.255 → 0.176`)。
取满整圈则不损失密度,因为半径增长时扫描本来就会覆盖所有角度。
注意这并不会让排布显得无序:半径仍与放置顺序高度相关(Pearson `r ≈ 0.98`)。
中心向外且保持紧密的填充必然按半径递增推进——「有序」与「紧密」是同一件事。
要在不牺牲密度的前提下打散这种观感,需要多个螺旋原点,而不是在单一螺旋上加抖动。
**兼容模式(`placement_mode=0`**:纯随机探测,取第一个合法位置。
三种模式共用后续步骤:
1. 随机探测失败后,从种子决定的偏移开始环形完整扫描,保证存在合法位置时一定能找到
2. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
3. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
真实字形搜索允许透明角落和笔画间空隙安全交错,比外接矩形碰撞更密。安全边距只参与候选检查,不会被双侧累计放大。
@@ -132,18 +156,50 @@ total_target = len(names) * N_REPETITIONS
1. 小画布完整布局放大到高清
2. 逐词在高清画布上验证,加入 `1px` 隔离带
3. 碰撞时只允许最大 `max_shift=24px` 局部微位移
4. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
5. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`
6. 全部候选无解时,由外层扩大画布后整批重新布局,不进行远距离单词搬移
3. 碰撞时先在 `max_shift=24px` 内做局部微位移
4. 邻域内无解时,`_find_free_placement()` 以逐步放大的窗口(`256 → 1024 → 全画布`)在整张画布上找空位,
取离原位置最近的一个。窗口内用积分图筛选:足迹范围内完全空白的位置必定可放,
无需逐像素比较,因此绝大多数候选位置只花两次加法就被排除。
这一步把「一个词放不下就整条流水线换更大画布重来」变成一次局部搬移——
后者是全流程中最贵的失败路径,实测会让端到端耗时翻倍且仍可能最终失败
5. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
6. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`
7. 全部候选无解时,才由外层扩大画布后整批重新布局
密度搜索会保留各档完整整批候选。最高密度候选若无法在有限位置修正范围内通过高清隔离验收,则改用上一档完整整批候选;不会对碰撞姓名单独缩字号,也不会接受带重叠的高密度结果。
## 字号缩放二分搜索
目标是找到**能放下全部姓名且轮廓覆盖最好的字号缩放**。单纯追求最大字号会让费马螺旋把词压在质心圆盘内、走不到掩膜远端,于是非圆形掩膜(心形尖端、人物四肢)填成圆形。
流水线用「探测」来判定某个缩放是否可行。探测在遇到第一个放不下的姓名时立刻停止:
一个姓名只有在螺旋、随机探测和全画布穷举扫描都失败后才算放不下,所以单个失败即可证明该缩放不可行,
不必把整批跑完。这一点对速度至关重要——放不下的姓名要付出完整搜索的代价,
实测约为可放下姓名的 `10` 倍,把注定失败的整批跑到底是流水线中最昂贵的操作。
`OptimizedEfficientWordCloud.max_failures` 控制这一行为,为 `None` 时跑满整批并尽量多放。
搜索过程:
1.`scale=1.0` 开始探测;失败则按 `sqrt(已放置比例)` 收缩再试,最多 `4`
2. 得到一个可行值后,在最大失败值与最小可行值之间二分最多 `3` 次,把之前收缩让掉的字号找回来
3. 相邻两个缩放取整后字号相同时停止——工作网格上字号是小整数,再细分没有意义
4. 多个可行结果中取**形状覆盖度最高**的那个(`compute_coverage_score`),覆盖度并列时才取缩放更大者
### 形状覆盖度
`compute_coverage_score()` 把可填区域切成 `8×8` 像素的块,只统计可填像素占比 ≥ 30% 的「区域块」,计算其中被墨迹触达的比例:
```
coverage = 被触达的区域块数 / 区域块总数
```
这是「轮廓是否被填出来」的直接度量:质心圆盘只触达中心几块,覆盖度低;铺进掩膜每个臂/尖端的布局触达各块,覆盖度高。块粒度(而非逐像素加权)让它对掩膜几何稳健——一个尖端无论宽 3px 还是 30px 都是一个块,触达它都被同等奖励。
## 填充率重试
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率`compute_coverage_score()` 计算形状覆盖度
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,只允许一次整批等比例增字号尝试。新布局必须仍然完整且真实填充率更高才会采用
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,进入**双向密度优化**:每轮同时探测「加大字号」和「减小字号」两个方向,取覆盖度更高的候选。减小字号让费马螺旋走更远、触达掩膜远端,即使填充率略降也能提升覆盖度——这正是纠正「填成圆形」的关键方向。覆盖度与填充率都无提升时停止
- 整批未完整放入时,流水线不会输出半成品:先整批等比例调整字号;触及最小字号仍失败时按 `CANVAS_RETRY_GROWTH` 扩大画布并重新生成
## 字号硬约束
@@ -176,3 +232,24 @@ new_w = clamp(base_w × scale_factor, max_edge=6000)
- 扩展后宽高向上取整到最近的 `100`
- 最大边长限制为 `6000px`,避免 SVG/PNG 过度膨胀
- 掩膜只生成一次,扩展后复用
## 性能:按字符缓存
一份中文名单里不同**字符**的数量远小于不同**姓名**的数量——750 个三字姓名通常只含约 `34` 个不同字符。
两处最重的工作因此按字符而不是按姓名缓存:
- **SVG 轮廓**`layout._char_shape`):每个 `(字符, 字号, 方向)` 只取一次字形轮廓并格式化一次路径字符串。
一个姓名由若干字符路径拼成,字符在词内的位置放进元素的 `transform` 平移量,
所以缓存的路径字符串被逐字节复用,不需要重新解析或平移坐标。
导出时每个字形输出一个 `<path>`,几何结果与整词单路径完全一致(已逐点验证)。
- **高清字形位图**`render._word_ink`):隔离精修与独立的重叠审计会在相同字号下光栅化相同姓名,
两者共用一份缓存。
`_path_bbox()` 只在每个字符首次构建时调用一次,词的包围盒由各字符包围盒平移后取并集算出,
不再对生成好的路径字符串做正则重解析。
## 零重叠保证
输出前 `count_layout_overlap_pixels()` 会独立重渲染整个 layout 并统计被两个及以上词占用的像素。
该值必须为 `0`,否则 `placement_ok` 为假,流水线拒绝输出而不是交付带重叠的结果。
这项校验独立于隔离精修,即使精修逻辑有误也能兜住。
+231
View File
@@ -0,0 +1,231 @@
# 画布模板导入导出包(`.wcd`)方案(规划)
> 状态:第一版已实现(画布导出 `.wcd`、首页导入 `.wcd`)。
> 目的:实现画布/设计的完整导入导出,要求包内不仅包含画布尺寸、背景、图层、元素摆放信息,还要把元素用到的素材一起带出去,使得包可以在另一台机器或另一个实例中导入复用。
## 1. 包格式
建议采用 Zip 包,文件后缀为 `.wcd`。没有必要自定义二进制格式。
预期目录结构:
```
example.wcd
├── manifest.json // 包元数据与 schema version
├── document.json // CanvasDocument:画布结构
├── preview.png // 可选封面图
├── fonts/ // 可选字体文件
│ └── ...
└── assets/
├── asset-001.svg
├── asset-002.png
└── asset-003.svg
```
## 2. manifest.json
```json
{
"format": "wordcloud-canvas",
"version": 1,
"name": "海报模板",
"description": "示例模板",
"createdAt": "2026-08-06T00:00:00Z",
"canvas": {
"width": 1600,
"height": 1000,
"background": "#ffffff"
},
"assets": [
{
"id": "asset-001",
"originalAssetId": "asset_xxx",
"name": "词云 A",
"type": "svg",
"mimeType": "image/svg+xml",
"sha256": "abc...",
"size": 1024
}
],
"fonts": []
}
```
字段说明:
- `format`:固定标识,防止其他 Zip 被误导入。
- `version`:包格式版本,后续升级时便于兼容。
- `assets[].id`:包内临时 ID,只在这个包内有效。
- `originalAssetId`:导出时的来源素材 ID,仅记录,不要求导入后保留。
- `sha256`:可选,导入时用于去重。
## 3. document.json
`document.json` 就是当前前端的 `CanvasDocument` 模型:
- `width`:画布宽度。
- `height`:画布高度。
- `background`:画布背景色。
- `layers`:图层列表。
- `layerFolders`:图层文件夹列表。
- `elements`:元素列表。
对 Sticker 元素有一个关键规则:**导出时把 `assetId` 替换成包内临时 ID**。
示例:
```json
{
"width": 1600,
"height": 1000,
"background": "#ffffff",
"layers": [
{ "id": "layer-1", "name": "词云", "visible": true, "locked": false }
],
"layerFolders": [],
"elements": [
{
"id": "element-1",
"type": "sticker",
"assetId": "asset-001",
"x": 100,
"y": 80,
"width": 800,
"height": 500,
"rotation": 0,
"opacity": 1
}
]
}
```
## 4. 导出流程
建议由后端提供导出接口,例如:
```text
GET /api/designs/{id}/export
```
或当前模板库扩展为:
```text
GET /api/design-templates/{id}/export
```
导出步骤:
1. 从数据库读取画布文档 `design_documents`
2. 序列化 `CanvasDocument`
3. 遍历 Sticker 元素,收集所有真实素材 ID。
4. 读取每个素材文件字节。
5. 为每个素材生成包内 ID,例如 `asset-001`
6. 用包内 ID 替换 `document.json` 中的 `assetId`
7. 把素材写入 `assets/` 目录。
8. 可选:生成 `preview.png` 作为导入时的缩略图。
9. 可选:如果模板使用了后端字体,把字体文件写入 `fonts/`
10. 生成 `manifest.json`
11. 打包为 `.wcd` 并返回。
## 5. 导入流程
建议后端提供导入接口,例如:
```text
POST /api/designs/import
multipart/form-data: file=.wcd
```
导入步骤:
1.`.wcd` 解压到临时目录。
2. 校验 `manifest.json`
- 是否是 `wordcloud-canvas` 格式。
- `version` 是否兼容。
- `document.json` 是否结构合法。
3. 读取 `document.json` 并通过现有 `normalizeDocument` 逻辑归一化。
4. 逐个处理 `assets/` 下素材:
- 计算 SHA-256。
- 如果素材表中已有相同 SHA-256,复用已有素材 ID。
- 否则调用素材导入逻辑写入 `assets` 表 + 文件系统。
5.`document.json` 中的包内 `assetId` 重新映射为真实素材 ID。
6. 保存为新的 `design_documents`
7. 可选:把 `preview.png` 作为模板封面。
8. 返回新设计/模板 ID。
## 6. 与现有模板系统的关系
当前模板保存是把 `CanvasDocument``reference_asset_ids` 写到目录 JSON 里:
- 在线模板:保持后端素材引用,适合当前实例内复用。
- `.wcd`:把素材一起打包,适合跨机器/离线/换实例导入导出。
两者最终统一到:
- `design_documents`:存画布文档。
- `assets`:存素材元数据。
- `design_templates`:存模板元数据并引用素材。
`.wcd` 只是外部交换容器。
## 7. 边界与设计决策
### 先不做自定义二进制格式
Zip + JSON 足够,方便调试、校验和后续扩展。
### 不把素材 base64 塞进 document.json
素材单独放文件,避免 JSON 膨胀;`document.json` 只保存引用 ID。
### 素材缺失处理
导出时如果某个素材文件缺失,可以选择:
- 导出失败并提示哪个素材缺失。
- 或在 `manifest` 中标记为 `missing`,导入时提示并跳过。
建议第一版采用“导出失败并提示”,保证导入包完整。
### 字体处理
第一版建议只保留 `fontFamily` 字符串,不打包字体。
后续如果确实需要跨机器还原,再把字体文件放进 `fonts/`,导入时注册到字体库。
### 去重
`assets.sha256` 是导入去重的关键字段:
- 包内相同素材只存一次。
- 多次导入相同素材时直接复用数据库中的现有素材。
## 8. 分阶段实施
### 阶段一:最小可用包(已完成)
- 定义 `.wcd`,包含 `manifest.json``document.json``assets/`
- 支持导出当前画布或模板。
- 支持解压导入,只处理贴纸素材和画布布局。
- 不处理字体,不生成 preview。
### 阶段二:导入体验完善
- 生成 `preview.png`
- 导入时检查素材缺失。
- 支持同一设计重复导入去重。
### 阶段三:与 PostgreSQL 打通
- `POST /api/designs/import` 最终写入 `design_documents`
- 素材导入自动写入 `assets` 表。
- 导出接口直接读取 `design_documents``assets`,不再依赖前端状态。
## 9. 相关文件参考
- `frontend/src/lib/svgExport.ts`:当前图层 ZIP 导出。
- `frontend/src/lib/templateLibrary.ts`:当前模板保存/读取。
- `frontend/src/lib/canvasDocument.ts``CanvasDocument` 模型与归一化。
- `frontend/src/types.ts``CanvasDocument``StickerAsset` 等类型。
- `backend/service/app.py`:当前 `/api/assets``/api/design-templates` 接口。
- `docs/DESIGN_DATA_STORAGE_PLAN.md`:存储层重构后文档落库设计。
+2
View File
@@ -34,6 +34,7 @@ backend/service_workspace/{job_id}/config.json
| `strokeWeights` | `ENABLE_STROKE_WEIGHTS` |
| `sizeRatio` | `SIZE_RATIO` |
| `packingEfficiency` | `PACKING_EFFICIENCY` |
| `verticalRatio` | `VERTICAL_RATIO` |
| `targetFillRatio` | `TARGET_FILL_RATIO` |
| `userMinFontSize` | `USER_MIN_FONT_SIZE` |
| `userMaxFontSize` | `USER_MAX_FONT_SIZE` |
@@ -92,6 +93,7 @@ backend/service_workspace/{job_id}/config.json
| `TARGET_FILL_RATIO` | `0.45` | 面积模型目标笔画填充率 |
| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例;`1.0` 为等字号模式 |
| `PACKING_EFFICIENCY` | `0.9` | 面积模型中的打包效率 |
| `VERTICAL_RATIO` | `0.18` | 竖排概率,逐词独立抽取;`0.0` 全部横排,`1.0` 全部竖排 |
### 字号硬约束
+163
View File
@@ -0,0 +1,163 @@
# 业务数据存储优化与指标报告
> 状态:已完成第一阶段可审计代码改造;未执行破坏性清理。
> 目标:减少无效任务文件、把任务元数据从“内存 + 散落目录 JSON”提升为“可恢复元数据存储”,并为后续 PostgreSQL 迁移留出接口。
## 1. 优化前现状
### 任务存储
- 任务状态 / 事件只存在于 `JobManager._jobs` 内存中,服务重启即丢失。
- `POST /api/jobs` 每次提交都会立刻创建 `service_workspace/{job_id}/input``output` 和配置文件。
- 任务完成后没有 TTL / 引用检查清理逻辑;历史任务目录会一直留在磁盘。
- `service_workspace` 中大量任务目录只是“曾经跑过一次”的产物,没有被任何素材或模板引用。
### 素材存储
- 素材文件保留在 `service_assets/{asset_id}/asset.*`,元数据写在目录内 `meta.json`
- 普通的 `POST /api/assets` 没有写入 `sha256`,只有 `.wcd` 导入路径开始做内容去重。
- 前端贴纸 `tint` 仍放在 localStorage,没有回到服务端统一维护。
### 当前实际磁盘基线(2026-08-06 扫描)
| 项 | 数量 / 大小 |
|---|---|
| `service_workspace` 任务目录 | 193 个 |
| `service_workspace` 总大小 | 2.44 GiB / 2,618,726,669 bytes |
| 被素材 `job_id` 引用的任务目录 | 6 个 |
| 未被任何素材引用的任务目录 | 187 个 |
| 未引用任务目录总大小 | 2.38 GiB / 2,556,506,642 bytes |
| 素材文件 | 40 个,合计约 95.6 MiB |
| 素材中重复内容多占空间 | 6 个额外文件,约 2.34 MiB |
| 设计模板 JSON | 4 个 |
## 2. 优化方案与已落地改动
### 1) 新增业务元数据存储层
新增 `backend/service/metadata_store.py`
- SQLite 单文件 `backend/service_metadata/app.db`
-`jobs``job_events` 两张表。
- 任务创建、状态更新、产物路径、SSE 事件都会落库。
- `JobManager` 启动时可以从数据库恢复任务,不再完全依赖内存。
- 表结构有意保持“一行元数据 + JSONB/JSON 字段”风格,后续迁移到 PostgreSQL 时主体字段不变。
### 2) 任务目录可审计与可清理
扩展 `backend/service/storage.py`
- `job_dir_size()` / `job_dir_info()`:按任务统计占用。
- `stale_job_dirs()`:按“未被素材引用、不在元数据库、可选按年龄”筛选遗留目录。
- `remove_job_dir()`:提供精确清理,只清理 `service_workspace` 下的任务目录。
新增 `backend/service/storage_metrics.py`
- 默认 `dry-run`,只扫描并输出可回收空间。
- 只有显式 `--apply` 才会删除遗留任务目录。
- 示例:
```bash
cd backend
.venv/bin/python -m service.storage_metrics --max-age-days 0
.venv/bin/python -m service.storage_metrics --max-age-days 0 --json ../docs/storage-metrics.json
# 确认后执行
.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply
```
### 3) 素材去重基础
- `.wcd` 导入路径已按 `sha256` 去重素材。
- `service_assets/{id}/meta.json` 中新增 `sha256` 字段。
- 后续 `POST /api/assets` 也可以统一补充哈希,形成服务级去重。
### 4) 可实时查询指标
新增只读接口:
```text
GET /api/maintenance/storage-summary
```
返回指标包括:
- `job_dir_count`:当前任务目录数。
- `referenced_job_ids`:被素材引用的任务数。
- `stale_job_count`:可回收任务数。
- `reclaimable_bytes`:可回收字节数。
- `jobs_in_db` / `events_in_db`:当前元数据分录数。
- `dry_run_only`: `true`,明确该接口不做删除。
## 3. 优化后指标
### 空间收益(只做审计,未执行删除)
| 指标 | 优化前 | 优化后可清理 | 优化后保留 |
|---|---:|---:|---:|
| 任务目录 | 193 | 187 | 6(被素材引用) |
| 任务文件占用 | 2.44 GiB | 2.38 GiB | ~59.3 MiB |
| 任务空间占用 | 100% | 可回收 97.6% | 首个保护区约 2.4% |
换算:
- 2,556,506,642 bytes ≈ 2.38 GiB。
- 若执行清理,仅任务目录可释放约 **2.38 GiB**
- 清理后任务目录可降到约 **59.3 MiB**,即保留的部分仍是当前贴纸真正引用的任务产物。
### 素材去重收益
当前 40 个素材文件中有 6 个属于重复内容,去重后:
- 少存 6 个文件。
- 可节省 2,455,630 bytes,约 **2.34 MiB**
- 素材文件数量从 40 → 34 个唯一内容。
这个数字目前不大,因为很多重复不到 100KB;真正大头仍是任务目录。
### 效能与可维护性收益
1. **任务可恢复**
- 原来重启服务后任务状态、进度、事件全部丢失。
- 现在 `jobs` / `job_events` 落库,启动时可恢复。
2. **查询由全盘扫描变为索引查询**
- 原来 `list_jobs` 只读内存;任务详情依赖内存里的事件列表。
- 现在有持久化事件表和 `job_id` 索引,可追溯历史。
3. **清理依据可计算**
- 原来“哪个目录能删”靠人工判断。
- 现在可统计“是否被素材引用、是否在元数据库、目录多老”,避免误删正在使用的任务。
4. **存储成本上限可控**
- 配合 TTL 清理,后续每新增任务产生的产物会在保留期后被回收。
- 不会继续无限制累积。
## 4. 建议后续执行步骤
1. 确认当前项目不再需要 187 个旧任务产物后,执行:
```bash
cd backend
.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply
```
2.`metadata_store.py` 从 SQLite 迁移到 PostgreSQL
- 安装 SQLAlchemy / asyncpg。
- `docker-compose.yml` 增加 PostgreSQL 服务。
-`jobs` / `job_events` / `assets` / `design_documents` 迁到 PG。
3. 把贴纸 `tint` 从 localStorage 迁到 `assets` 元数据,并由后端 `PATCH /api/assets/{id}` 维护。
4. `POST /api/assets` 统一补 `sha256`,实现服务级素材去重。
5. 增加后台定时清理任务,例如保留 7 天、30 天两档。
## 5. 相关文件
- `backend/service/metadata_store.py`
- `backend/service/job_manager.py`
- `backend/service/storage.py`
- `backend/service/storage_metrics.py`
- `backend/service/app.py`
- `docs/DESIGN_DATA_STORAGE_PLAN.md`
+173
View File
@@ -0,0 +1,173 @@
# 存储与数据库重构方案(规划)
> 状态:第一阶段部分已落地(任务元数据落 SQLite、任务清理审计、素材 sha256)。
> 目的:后端当前仍以“内存 + 文件 + 任务级 SQLite + meta.json”为主要存储方式。本文档规划后续迁移到 PostgreSQL 元数据库,并优化任务生命周期和贴纸持久化。
## 1. 当前现状
| 数据 | 当前存储 | 问题 |
|---|---|---|
| 任务状态 / 事件 | `JobManager._jobs` 仅存内存 | 重启服务后任务记录丢失 |
| 任务输入 / 输出 | `backend/service_workspace/{job_id}` | 提交任务即建目录,未完成或被放弃的任务会遗留文件 |
| 词云坐标结果 | 每个任务生成一个 `word_locations.db`(SQLite | 每个任务自带一份 SQLite 文件,查询分散 |
| 贴纸素材 | `backend/service_assets/asset_xxx/asset.svg` + `meta.json` | 素材元数据不是数据库,tint 等前端信息还依赖 localStorage |
| 设计模板 / 工程 | `service_design_templates` / `service_projects` 目录 + JSON | 模板和工程之间缺少数据库关联 |
| 画布文档 | 前端 localStorage | 无法跨设备,也无法作为后端权威数据 |
注意:当前不能认为系统已经在使用 PostgreSQL。代码中出现的 `*.db` 是词云算法自己写的 SQLite 结果文件,例如 `backend/core/pipeline.py``word_locations` 表。
## 2. 目标架构
整体原则:
- **文件继续存文件系统或对象存储**(SVG / PNG / 遮罩 / Excel / 字体)。
- **业务元数据和引用关系存 PostgreSQL**。
- 数据库保存路径引用,不保存大文件内容。
目标模型:
| 表 | 用途 | 说明 |
|---|---|---|
| `jobs` | 任务主表 | job_id、状态、参数 JSONB、产物引用、创建时间 |
| `job_events` | 任务进度事件 | SSE 进度事件落库,服务重启后可恢复 |
| `assets` | 贴纸 / 素材表 | 素材元数据、文件路径、来源 job、sha256、tint |
| `design_documents` | 画布文档 | CanvasDocument JSONB,绑定模板/工程 |
| `design_templates` | 模板 | 模板元数据 + 画布文档引用 |
| `projects` | 工程 | 模板 + 画布文档 + 素材引用 |
### jobs 表字段建议
```text
id uuid pk
status text -- submitted/running/success/failed/cancelled
stage text
progress int
message text
params jsonb -- 用户提交的词云参数
input_files jsonb -- mask/excel/font 引用
artifacts jsonb -- png/svg/svg_stroke/db/metrics 路径或文件 id
error text
created_at timestamptz
updated_at timestamptz
retention_until timestamptz -- 清理时间
```
### assets 表字段建议
```text
id uuid pk
name text
type text -- wordcloud / upload / shape / reference
mime_type text
storage_key text -- 文件系统路径或对象存储 key
width int
height int
file_size bigint
sha256 text -- 用于导入去重
source_job_id uuid nullable
tint text nullable
created_at timestamptz
deleted_at timestamptz nullable
```
## 3. 任务存储链路
现状是 `POST /api/jobs` 提交时直接创建 job 目录和保存上传文件。
目标改动:
1. `POST /api/jobs` 只写 `jobs` 表,状态为 `submitted``queued`
2. 上传文件先落到临时上传区,或延迟到进入 runner 前再落盘。
3. runner 真正开始时才创建任务的 `input/``output/` 目录。
4. 任务完成后把产物路径/文件 id 写入 `jobs.artifacts`
5. 增加后台清理任务:
- 清理 `completed` 且未被贴纸/工程引用的任务文件。
- 支持按 `retention_until` 保留最近结果。
- 被用户导入为贴纸的任务文件可延长保留时间。
这样不会每次申请都攒下一堆用不上的目录和文件。
## 4. 贴纸持久化
贴纸在当前 `frontend/src/lib/stickerLibrary.ts` 中已经走后端 `POST /api/assets`,但元数据仍写在 `meta.json`tint 还保存在 localStorage。
目标改动:
- `assets` 表作为贴纸唯一权威来源。
- `POST /api/assets`:写文件系统 + 写 `assets` 表,返回 `asset_id`
- `GET /api/assets`:从数据库读取列表。
- `PATCH /api/assets/{id}`:更新 tint、name 等元数据。
- `DELETE /api/assets/{id}`:物理删除文件 + 记录,或软删除防止破坏设计文档引用。
- `POST /api/assets/from-job/{job_id}`:沿用同一逻辑,写入 `source_job_id`
- 前端不再依赖 localStorage 保存贴纸 tint,加载和更新都走 API。
## 5. 画布文档与模板
当前画布保存在 localStorage,模板保存成目录 JSON。
目标改动:
- `design_documents` 保存 `CanvasDocument` JSONB。
- 画布每次保存调用 `PUT /api/documents/{id}`
- `design_templates` 引用 `design_documents`,同时记录 `reference_asset_ids` 和封面图。
- 后续实现画布导出导入时,导入包可直接写入 `design_documents`,并把包内素材批量写入 `assets` 表。
## 6. PostgreSQL 接入方式
建议:
- 引入 SQLAlchemy(或 asyncpg)作为数据库访问层。
- 使用 Alembic 管理 migration。
-`docker-compose.yml` 增加 PostgreSQL 服务。
- 通过环境变量注入 `DATABASE_URL`,本地开发和 Docker 使用不同配置。
- 暂不把词云算法的 `word_locations` 表强制迁移到 PostgreSQL,可以保留 SQLite 作为任务内部产物,再通过导出接口把需要的布局结果写入 `jobs` 或独立布局表中。
## 7. 分阶段实施
### 阶段一:接入 PostgreSQL,先做贴纸和任务元数据(任务元数据已用 SQLite 先行落地)
-`assets` / `jobs` / `job_events` 表。
- `assets` 接口从文件 meta 迁移到 DB。
- 提交任务仍可使用现有 runner,但把任务状态写入 DB。
- 不改动词云算法核心。
### 阶段二:任务生命周期优化(清理审计已落地)
- `POST /api/jobs` 只记账,不提前建目录。
- runner 开始前再落 input/output。
- 增加 TTL 清理任务。
- 任务列表、任务详情改为从 DB 查询。
### 阶段三:画布文档和导入包
-`design_documents` / `design_templates` / `projects` 表。
- 画布保存从 localStorage 改为后端文档接口。
- `wcd` 导入导出包直接对接这些表。
## 8. 风险与注意点
- 现有任务接口依赖内存中的 `JobManager`,迁到 DB 后需要兼容 SSE 进度事件。
- 文件迁移只能做增量:老素材目录可先保留,新写入走 DB。
- 删除素材要检查 `design_documents` 引用,避免出现缺失贴纸。
- tint 从前端 localStorage 迁移到 DB 时,需要兼容旧浏览器状态。
## 10. 已落地实现
- `backend/service/metadata_store.py`SQLite 元数据 `jobs` / `job_events`
- `backend/service/job_manager.py`:任务状态和事件落库,服务重启可恢复。
- `backend/service/storage.py`:任务目录占用、过期审计、可清理能力。
- `backend/service/storage_metrics.py`dry-run 指标和显式 `--apply` 清理。
- `backend/service/app.py``GET /api/maintenance/storage-summary`
- `docs/DATA_STORAGE_OPTIMIZATION.md`:完整空间/效能指标。
> 注:当前项目没有接入 PostgreSQL。代码里的 `*.db` 是词云算法自己的 SQLite 结果文件;新加的 `service_metadata/app.db` 是业务元数据先行层。`jobs` / `job_events` 表结构设计上可平滑迁移到 PostgreSQL。
## 9. 相关文件参考
- `backend/service/app.py`:目前的任务、素材、模板 API。
- `backend/service/job_manager.py`:内存中的任务状态。
- `backend/service/storage.py`:任务目录创建。
- `backend/service/runner.py`:任务运行与产物扫描。
- `backend/core/pipeline.py`:词云结果 SQLite 写入。
- `frontend/src/lib/stickerLibrary.ts`:前端贴纸库。
- `docker-compose.yml`:服务编排,后续加 PostgreSQL。
+25 -1
View File
@@ -178,6 +178,12 @@ export default function AdvancedPanel({
</div>
</div>
<Hint> 5~10</Hint>
<BoolField
label="自动重复填充至轮廓完整"
checked={params.autoRepeatToFill}
onChange={v => onParamsChange({ autoRepeatToFill: v })}
hint="开启后,当掩膜轮廓填不满时自动循环追加名字副本,直到形状轮廓填充完毕(最多 20 次)"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
@@ -198,8 +204,17 @@ export default function AdvancedPanel({
step={0.01}
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.9 })}
/>
<NumberField
label="竖排概率 VERTICAL_RATIO"
value={params.verticalRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ verticalRatio: v ?? 0.18 })}
/>
</div>
<Hint>SIZE_RATIO 2.0</Hint>
<Hint>VERTICAL_RATIO 0.18</Hint>
<div className="form-row">
<NumberField
@@ -235,7 +250,7 @@ export default function AdvancedPanel({
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ workScale: v ?? 0.2 })}
onChange={v => onParamsChange({ workScale: v ?? 0.18 })}
/>
<NumberField
label="目标填充率 TARGET_FILL"
@@ -257,6 +272,15 @@ export default function AdvancedPanel({
hint="开启后笔画复杂的字更大;关闭则更接近均等字号"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="生成调试文件"
checked={params.saveDebugImages}
onChange={v => onParamsChange({ saveDebugImages: v })}
hint="开启后保存掩膜和占用网格等中间图片;正常生成建议关闭以减少磁盘 I/O"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-row">
+4 -2
View File
@@ -9,10 +9,11 @@ interface CanvasAreaProps {
viewMode: '2d' | '3d';
zoom: number;
highlightLocation: NameLocation | null;
onImageLoaded?: () => void;
}
export default function CanvasArea({
maskFile, jobResult, viewMode, zoom, highlightLocation
maskFile, jobResult, viewMode, zoom, highlightLocation, onImageLoaded
}: CanvasAreaProps) {
const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -44,7 +45,7 @@ export default function CanvasArea({
</div>
) : viewMode === '3d' ? (
<div className="view-3d-container">
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" />
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" onLoad={onImageLoaded} />
</div>
) : (
<div className="canvas-image-wrapper">
@@ -52,6 +53,7 @@ export default function CanvasArea({
src={displayUrl}
alt="wordcloud"
className="canvas-image"
onLoad={onImageLoaded}
style={{ transform: `scale(${zoom})` }}
/>
{highlightLocation && jobResult && (
+9
View File
@@ -268,3 +268,12 @@ export function IconHelp() {
</Icon>
);
}
export function IconCopy() {
return (
<Icon>
<rect x="4" y="2" width="9" height="11" rx="1.5" />
<path d="M3 5h-.5a1.5 1.5 0 0 0-1.5 1.5v6A1.5 1.5 0 0 0 2.5 14h6A1.5 1.5 0 0 0 10 12.5V12" />
</Icon>
);
}
+70 -3
View File
@@ -1,12 +1,33 @@
import { useCallback, useEffect, useRef } from 'react';
import { SSEProgress } from '../types';
import { IconCross, IconCheckmark, IconGear } from './Icons';
import { IconCross, IconCheckmark, IconGear, IconCopy } from './Icons';
interface ProgressPanelProps {
progress: SSEProgress | null;
logLines: string[];
visible: boolean;
}
export default function ProgressPanel({ progress, visible }: ProgressPanelProps) {
export default function ProgressPanel({ progress, logLines, visible }: ProgressPanelProps) {
const logEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll the log view to the bottom whenever new lines arrive.
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [logLines]);
// All hooks MUST run before any early return, so derive the values the
// callback needs without depending on `progress` being non-null.
const message = progress?.message ?? '';
const handleCopyError = useCallback(() => {
const text = logLines.length > 0 ? logLines.join('\n') : message;
navigator.clipboard.writeText(text).catch(() => {
const el = document.querySelector('.error-detail-textarea') as HTMLTextAreaElement | null;
if (el) { el.select(); document.execCommand('copy'); }
});
}, [logLines, message]);
if (!visible || !progress) return null;
const isFailed = progress.stage === '生成失败' || progress.stage === '错误';
@@ -20,6 +41,13 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
<div className="progress-stage" style={isFailed ? { color: 'var(--danger)' } : {}}>
{progress.stage}
</div>
{(progress.elapsedSeconds != null || progress.clientElapsedSeconds != null) && (
<div className="progress-timing" style={{ color: isDone ? 'var(--success)' : 'var(--text-muted)' }}>
{progress.elapsedSeconds != null && `后端耗时 ${progress.elapsedSeconds.toFixed(2)}`}
{progress.elapsedSeconds != null && progress.clientElapsedSeconds != null ? ' · ' : ''}
{progress.clientElapsedSeconds != null && `前端显示耗时 ${progress.clientElapsedSeconds.toFixed(2)}`}
</div>
)}
{!isFailed && (
<div className="progress-bar-track">
<div
@@ -31,6 +59,33 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
/>
</div>
)}
{/* ── 详细日志(实时滚动) ───────────────────────────────── */}
{logLines.length > 0 && (
<div
className="progress-log-view"
style={{
marginTop: 8,
maxHeight: 320,
overflowY: 'auto',
background: 'var(--bg-secondary, #1a1a2e)',
borderRadius: 6,
padding: '8px 10px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: 12,
lineHeight: 1.6,
color: 'var(--text-muted, #888)',
border: '1px solid var(--border-color, #333)',
}}
>
{logLines.map((line, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{line}
</div>
))}
<div ref={logEndRef} />
</div>
)}
{/* ── 失败时的错误详情 + 复制按钮 ───────────────────────── */}
<div
className="progress-message"
style={{
@@ -41,7 +96,19 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
marginTop: isFailed ? 8 : 0,
}}
>
{progress.message}
{isFailed ? (
<div className="error-detail-container">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleCopyError}
title="复制完整日志"
style={{ marginBottom: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<IconCopy />
</button>
</div>
) : null}
</div>
</div>
);
+116
View File
@@ -0,0 +1,116 @@
import { CanvasDocument, StickerAsset } from '../types';
import { normalizeDocument } from './canvasDocument';
import { apiUrl } from './api';
import { createZip, ZipFileInput } from './zip';
interface PackageAssetMeta {
id: string;
originalAssetId: string;
name: string;
type: string;
mimeType: string;
size: number;
}
function stickerMimeType(asset: StickerAsset): string {
if (asset.mimeType) return asset.mimeType;
return asset.type === 'svg' ? 'image/svg+xml' : 'image/png';
}
function stickerFileExtension(asset: StickerAsset): string {
if (asset.type === 'svg') return '.svg';
if (asset.mimeType === 'image/jpeg') return '.jpg';
if (asset.mimeType === 'image/png') return '.png';
const source = asset.source.toLowerCase();
if (source.endsWith('.jpg') || source.endsWith('.jpeg')) return '.jpg';
return '.png';
}
export function safePackageBaseName(name: string): string {
const cleaned = name.trim().replace(/[\\/:*?"<>|\n\t]/g, '_').replace(/\s+/g, '_').slice(0, 80);
return cleaned || '画布设计';
}
export async function exportCanvasPackage(
documentModel: CanvasDocument,
stickerById: Map<string, StickerAsset>,
name = '画布设计',
description = '',
): Promise<Blob> {
const doc = normalizeDocument(documentModel);
const usedAssetIds: string[] = [];
const seen = new Set<string>();
doc.elements.forEach(element => {
if (element.type !== 'sticker') return;
if (seen.has(element.assetId)) return;
seen.add(element.assetId);
usedAssetIds.push(element.assetId);
});
const packageIdByAsset = new Map<string, string>();
usedAssetIds.forEach((assetId, index) => {
packageIdByAsset.set(assetId, `asset-${String(index + 1).padStart(3, '0')}`);
});
const files: ZipFileInput[] = [];
const packageAssets: PackageAssetMeta[] = [];
for (const assetId of usedAssetIds) {
const asset = stickerById.get(assetId);
if (!asset) throw new Error(`画布引用了缺失素材:${assetId}`);
const res = await fetch(apiUrl(asset.source));
if (!res.ok) throw new Error(`读取素材失败:${asset.name} (${res.status})`);
const bytes = new Uint8Array(await res.arrayBuffer());
const packageId = packageIdByAsset.get(assetId) || assetId;
const ext = stickerFileExtension(asset);
packageAssets.push({
id: packageId,
originalAssetId: assetId,
name: asset.name,
type: asset.type,
mimeType: stickerMimeType(asset),
size: bytes.length,
});
files.push({
name: `assets/${packageId}${ext}`,
content: bytes,
});
}
const packageDocument = {
...doc,
elements: doc.elements.map(element => {
if (element.type !== 'sticker') return element;
return {
...element,
assetId: packageIdByAsset.get(element.assetId) || element.assetId,
};
}),
};
const manifest = {
format: 'wordcloud-canvas',
version: 1,
name: name.trim() || '画布设计',
description: description.trim(),
createdAt: new Date().toISOString(),
canvas: {
width: doc.width,
height: doc.height,
background: doc.background,
},
assets: packageAssets,
fonts: [],
};
files.unshift({
name: 'manifest.json',
content: JSON.stringify(manifest, null, 2),
});
files.splice(1, 0, {
name: 'document.json',
content: JSON.stringify(packageDocument, null, 2),
});
return createZip(files);
}
+3
View File
@@ -115,6 +115,7 @@ export async function loadStickerLibrary(): Promise<StickerAsset[]> {
source: a.file_url,
createdAt: a.created_at,
tint: tints[a.asset_id] as StickerAsset['tint'],
mimeType: a.mime_type,
}));
}
@@ -136,6 +137,7 @@ export async function addStickerAsset(
source: asset.file_url,
createdAt: asset.created_at,
tint: input.tint,
mimeType: asset.mime_type,
};
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
return sticker;
@@ -160,6 +162,7 @@ export async function addStickerAssetFromJob(
type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image',
source: asset.file_url,
createdAt: asset.created_at,
mimeType: asset.mime_type,
};
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
return sticker;
+25 -4
View File
@@ -6,6 +6,8 @@ import { createZip } from './zip';
export interface SerializeOptions {
layerIds?: string[];
includeBackground?: boolean;
/** Add two in-canvas registration dots for physical/image alignment. */
addRegistrationMarks?: boolean;
}
async function fetchBlobAsDataUrl(url: string): Promise<string> {
@@ -108,6 +110,11 @@ export async function serializeDocument(
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
}
// Keep marks last so canvas elements cannot cover the alignment targets.
if (options.addRegistrationMarks) {
parts.push(serializeRegistrationMarks(doc.width, doc.height));
}
parts.push('</svg>');
return parts.join('\n');
}
@@ -117,6 +124,7 @@ export async function createLayerExportZip(
stickerById: Map<string, StickerAsset>,
selectedLayerIds: string[],
selectedFolderIds: string[],
options: Pick<SerializeOptions, 'addRegistrationMarks'> = {},
) {
const doc = normalizeDocument(documentModel);
const files: { name: string; content: string }[] = [];
@@ -125,7 +133,7 @@ export async function createLayerExportZip(
if (hasCanvasBackground(doc.background)) {
files.push({
name: uniqueSvgName('背景', used),
content: serializeBackgroundLayer(doc),
content: serializeBackgroundLayer(doc, options),
});
}
@@ -134,7 +142,7 @@ export async function createLayerExportZip(
if (!layer) continue;
files.push({
name: uniqueSvgName(layer.name, used),
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }),
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false, ...options }),
});
}
@@ -143,24 +151,37 @@ export async function createLayerExportZip(
if (!folder) continue;
files.push({
name: uniqueSvgName(folder.name, used),
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }),
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false, ...options }),
});
}
return createZip(files);
}
function serializeBackgroundLayer(documentModel: CanvasDocument) {
function serializeBackgroundLayer(documentModel: CanvasDocument, options: Pick<SerializeOptions, 'addRegistrationMarks'> = {}) {
const doc = normalizeDocument(documentModel);
const widthMm = pxToMm(doc.width).toFixed(1);
const heightMm = pxToMm(doc.height).toFixed(1);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
...(options.addRegistrationMarks ? [serializeRegistrationMarks(doc.width, doc.height)] : []),
'</svg>',
].join('\n');
}
function serializeRegistrationMarks(width: number, height: number) {
const minDimension = Math.max(1, Math.min(width, height));
// Keep the circles inside the canvas so neither SVG nor raster consumers clip them.
const inset = Math.max(4, minDimension * 0.012);
const radius = Math.max(1.5, minDimension * 0.004);
const format = (value: number) => formatSvgNumber(value);
return [
`<circle cx="${format(inset)}" cy="${format(inset)}" r="${format(radius)}" fill="#000000"/>`,
`<circle cx="${format(width - inset)}" cy="${format(height - inset)}" r="${format(radius)}" fill="#000000"/>`,
].join('\n');
}
function serializeInlineSvgSticker(
svgText: string,
targetWidth: number,
+14
View File
@@ -46,6 +46,20 @@ export async function createCanvasTemplate(input: {
return { ...template, document: normalizeDocument(template.document) };
}
export async function importCanvasTemplate(
file: File,
name = '',
description = '',
): Promise<CanvasTemplate> {
const fd = new FormData();
fd.append('file', file);
if (name) fd.append('name', name.trim());
if (description) fd.append('description', description.trim());
const res = await ensureOk(await fetch(apiUrl('/api/design-templates/import'), { method: 'POST', body: fd }), '导入设计包失败');
const template = (await res.json()) as CanvasTemplate;
return { ...template, document: normalizeDocument(template.document) };
}
export async function updateCanvasTemplate(
id: string,
partial: {
+158 -8
View File
@@ -32,6 +32,7 @@ import {
} from '../lib/canvasDocument';
import { createCanvasTemplate, duplicateDocument, uploadAsset } from '../lib/templateLibrary';
import { createLayerExportZip, serializeDocument } from '../lib/svgExport';
import { exportCanvasPackage, safePackageBaseName } from '../lib/canvasPackage';
import { apiUrl, ensureOk } from '../lib/api';
import {
IconGrid,
@@ -461,16 +462,16 @@ export default function CanvasStudio({
});
};
const exportSvg = useCallback(async () => {
const svg = await serializeDocument(normalizedDocument, stickerById);
const exportSvg = useCallback(async (addRegistrationMarks: boolean) => {
const svg = await serializeDocument(normalizedDocument, stickerById, { addRegistrationMarks });
downloadBlob(
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
'canvas-design.svg',
);
}, [normalizedDocument, stickerById]);
const exportLayerZip = async (layerIds: string[], folderIds: string[]) => {
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds);
const exportLayerZip = async (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => {
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks });
downloadBlob(blob, 'canvas-layers.zip');
};
@@ -858,6 +859,7 @@ export default function CanvasStudio({
activeLayerId={activeLayerId}
onActiveLayerChange={setActiveLayerId}
onChange={setDocumentModel}
stickerById={stickerById}
/>
);
case 'sticker':
@@ -1096,16 +1098,121 @@ export default function CanvasStudio({
);
}
function LayerThumbnail({
documentModel,
layer,
stickerById,
}: {
documentModel: CanvasDocument;
layer: CanvasLayer;
stickerById: Map<string, StickerAsset>;
}) {
const elements = documentModel.elements.filter(element => element.layerId === layer.id);
const fitScale = Math.min(1, 52 / Math.max(documentModel.width, documentModel.height, 1));
const scaledWidth = Math.max(1, documentModel.width * fitScale);
const scaledHeight = Math.max(1, documentModel.height * fitScale);
return (
<div className={`layer-thumb${layer.visible === false ? ' layer-thumb-hidden' : ''}`}>
<div
className="layer-thumb-scale"
style={{ width: scaledWidth, height: scaledHeight }}
>
<div
className="layer-thumb-doc"
style={{ width: documentModel.width, height: documentModel.height, transform: `scale(${fitScale})` }}
>
{elements.map(element => (
<LayerThumbElement
key={element.id}
element={element}
asset={element.type === 'sticker' ? stickerById.get(element.assetId) : undefined}
/>
))}
</div>
</div>
</div>
);
}
function LayerThumbElement({
element,
asset,
}: {
element: CanvasElement;
asset?: StickerAsset;
}) {
const baseStyle: CSSProperties = {
position: 'absolute',
left: element.x,
top: element.y,
width: element.width,
height: element.height,
opacity: element.opacity,
transform: `rotate(${element.rotation}deg)`,
};
if (element.type === 'sticker') {
if (!asset) return (<div className="missing-sticker" style={baseStyle}></div>);
return (
<img
className={`studio-sticker-image${asset.tint === 'gray' ? ' gray-mask' : ''} layer-thumb-image`}
style={baseStyle}
src={assetToDataUrl(asset)}
alt={asset.name}
draggable={false}
/>
);
}
if (element.type === 'text') {
return (
<div
className="layer-thumb-text"
style={{
...baseStyle,
color: element.fill,
fontFamily: element.fontFamily,
fontSize: element.fontSize,
fontWeight: element.fontWeight,
}}
>
{element.text}
</div>
);
}
if (element.type === 'line') {
return (
<div className="layer-thumb-line" style={baseStyle}>
<div style={{ width: '100%', height: Math.max(1, element.strokeWidth), background: element.stroke }} />
</div>
);
}
return (
<div
className={`layer-thumb-shape${element.type === 'ellipse' ? ' layer-thumb-ellipse' : ''}`}
style={{
...baseStyle,
background: element.fill === 'transparent' ? undefined : element.fill,
border: element.strokeWidth > 0 ? `${Math.max(0, element.strokeWidth)}px solid ${element.stroke}` : undefined,
}}
/>
);
}
function LayersPanel({
documentModel,
activeLayerId,
onActiveLayerChange,
onChange,
stickerById,
}: {
documentModel: CanvasDocument;
activeLayerId: string;
onActiveLayerChange: (id: string) => void;
onChange: (documentModel: CanvasDocument) => void;
stickerById: Map<string, StickerAsset>;
}) {
const layers = documentModel.layers || [];
const folders = documentModel.layerFolders || [];
@@ -1206,6 +1313,10 @@ function LayersPanel({
))}
{layers.slice().reverse().map(layer => (
<div key={layer.id} className={`layer-row${layer.id === activeLayerId ? ' active' : ''}`}>
<div className="layer-thumb-wrap">
<LayerThumbnail documentModel={documentModel} layer={layer} stickerById={stickerById} />
</div>
<div className="layer-info">
<div className="layer-main-line">
<button className="icon-btn layer-icon-btn" title="显示/隐藏" onClick={() => updateLayer(layer.id, { visible: !layer.visible })}>
{layer.visible ? <IconEyeOpen /> : <IconEyeClosed />}
@@ -1235,6 +1346,7 @@ function LayersPanel({
<button className="icon-btn layer-icon-btn" title="删除" onClick={() => deleteLayer(layer.id)}><IconTrash /></button>
</div>
</div>
</div>
</div>
))}
</div>
@@ -1308,8 +1420,8 @@ function CanvasExportPanel({
documentModel: CanvasDocument;
stickerById: Map<string, StickerAsset>;
onUpdateDocument: (partial: Partial<CanvasDocument>) => void;
onExportSvg: () => void;
onExportLayerZip: (layerIds: string[], folderIds: string[]) => void;
onExportSvg: (addRegistrationMarks: boolean) => void;
onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void;
onReset: () => void;
}) {
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
@@ -1318,6 +1430,8 @@ function CanvasExportPanel({
const [templateName, setTemplateName] = useState('');
const [templateDescription, setTemplateDescription] = useState('');
const [referenceFiles, setReferenceFiles] = useState<File[]>([]);
const [addRegistrationMarks, setAddRegistrationMarks] = useState(false);
const [exportingPackage, setExportingPackage] = useState(false);
const layers = documentModel.layers || [];
const folders = documentModel.layerFolders || [];
const backgroundEnabled = hasCanvasBackground(documentModel.background);
@@ -1355,6 +1469,24 @@ function CanvasExportPanel({
}
};
const exportPackage = async () => {
if (exportingPackage) return;
setExportingPackage(true);
try {
const blob = await exportCanvasPackage(
documentModel,
stickerById,
templateName || '画布设计',
templateDescription,
);
downloadBlob(blob, `${safePackageBaseName(templateName || '画布设计')}.wcd`);
} catch (error) {
alert(error instanceof Error ? error.message : '导出设计包失败');
} finally {
setExportingPackage(false);
}
};
return (
<>
<div className="form-row">
@@ -1412,7 +1544,18 @@ function CanvasExportPanel({
/>
</div>
)}
<button className="btn btn-primary btn-block" onClick={onExportSvg}> SVG</button>
<div className="form-group">
<label className="checkbox-row">
<input
type="checkbox"
checked={addRegistrationMarks}
onChange={event => setAddRegistrationMarks(event.target.checked)}
/>
<span></span>
</label>
<div className="note-text"></div>
</div>
<button className="btn btn-primary btn-block" onClick={() => onExportSvg(addRegistrationMarks)}> SVG</button>
<div className="section-divider" />
<div className="section-title"></div>
@@ -1441,7 +1584,7 @@ function CanvasExportPanel({
</div>
<button
className="btn btn-secondary btn-block"
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds)}
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds, addRegistrationMarks)}
>
SVG
</button>
@@ -1469,6 +1612,13 @@ function CanvasExportPanel({
<button className="btn btn-secondary btn-block" disabled={savingTemplate} onClick={saveTemplate}>
{savingTemplate ? '保存中' : '保存当前画布为模板'}
</button>
<button
className="btn btn-secondary btn-block"
disabled={exportingPackage}
onClick={exportPackage}
>
{exportingPackage ? '打包中' : '导出 .wcd'}
</button>
<button className="btn btn-danger btn-block" onClick={onReset}></button>
<span style={{ display: 'none' }}>{stickerById.size}</span>
</>
+33 -1
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import { BackendAsset, CanvasTemplate } from '../types';
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
import {
assetUrl,
deleteCanvasTemplate,
importCanvasTemplate,
listAssets,
listDesignTemplates,
templateCoverId,
@@ -19,6 +20,7 @@ import {
IconCloud,
IconRefresh,
IconCanvas,
IconDownload,
IconHelp,
} from '../components/Icons';
@@ -48,7 +50,9 @@ export default function TemplateHome({
const [selected, setSelected] = useState<CanvasTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [importing, setImporting] = useState(false);
const [stickerById, setStickerById] = useState(() => new Map<string, import('../types').StickerAsset>());
const importFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadStickerLibrary().then(items => {
@@ -92,6 +96,23 @@ export default function TemplateHome({
refresh();
};
const handleImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file || importing) return;
setImporting(true);
setError('');
try {
await importCanvasTemplate(file);
await refresh();
alert('设计包已导入');
} catch (err) {
setError(err instanceof Error ? err.message : '导入设计包失败');
} finally {
setImporting(false);
}
};
return (
<div className="template-home">
<nav className="navbar">
@@ -104,6 +125,17 @@ export default function TemplateHome({
<span className="nav-btn-icon"><IconRefresh /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={() => importFileRef.current?.click()} disabled={importing}>
<span className="nav-btn-icon"><IconDownload /></span>
<span className="nav-btn-label">{importing ? '导入中' : '导入 .wcd'}</span>
</button>
<input
ref={importFileRef}
style={{ display: 'none' }}
type="file"
accept=".wcd"
onChange={handleImportFile}
/>
</div>
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenCanvas}>
+112 -5
View File
@@ -45,14 +45,21 @@ const DEFAULT_PARAMS: JobParams = {
fontColor: '#000000',
nRepetitions: 1,
strokeWeights: true,
autoRepeatToFill: true,
// Intermediate mask/occupancy images are useful for diagnosis but add
// extra disk I/O, so keep them disabled for normal generation.
saveDebugImages: false,
// 字号与填充
sizeRatio: 2.0,
packingEfficiency: 0.9,
verticalRatio: 0.18,
targetFillRatio: 0.45,
userMinFontSize: null,
userMaxFontSize: null,
minReadableHeightPx: 22,
// 0.18 keeps the 750-word service request complete on the default mask;
// lower values can make the coarse grid too small to place every word.
workScale: 0.18,
// 画布
@@ -118,7 +125,9 @@ export default function TestWorkbench({
const [jobId, setJobId] = useState<string | null>(null);
const [jobResult, setJobResult] = useState<JobResult | null>(null);
const [progress, setProgress] = useState<SSEProgress | null>(null);
const [logLines, setLogLines] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [clientElapsedSeconds, setClientElapsedSeconds] = useState<number | null>(null);
const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']);
const [activeReplaceSession, setActiveReplaceSession] = useState<WordcloudReplaceSession | null>(null);
const [replaceMaskReady, setReplaceMaskReady] = useState(false);
@@ -130,6 +139,10 @@ export default function TestWorkbench({
const [fonts, setFonts] = useState<Font[]>([]);
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
const sseRef = useRef<EventSource | null>(null);
const requestStartedAtRef = useRef<number | null>(null);
const displayedJobRef = useRef<string | null>(null);
const previewRequestedJobRef = useRef<string | null>(null);
const displayElapsedRef = useRef<number | null>(null);
const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT);
const focusWorkbenchPanel = floatingPanels.focusPanel;
@@ -408,7 +421,13 @@ export default function TestWorkbench({
}
setIsGenerating(true);
requestStartedAtRef.current = performance.now();
displayedJobRef.current = null;
previewRequestedJobRef.current = null;
displayElapsedRef.current = null;
setClientElapsedSeconds(null);
setProgress({ stage: '准备中', percent: 0, message: '正在提交任务...' });
setLogLines([]);
setJobResult(null);
setHighlightLocation(null);
@@ -427,8 +446,11 @@ export default function TestWorkbench({
FONT_COLOR: params.fontColor || '#000000',
N_REPETITIONS: params.nRepetitions,
ENABLE_STROKE_WEIGHTS: params.strokeWeights,
AUTO_REPEAT_TO_FILL: params.autoRepeatToFill,
SAVE_DEBUG_IMAGES: params.saveDebugImages,
SIZE_RATIO: params.sizeRatio,
PACKING_EFFICIENCY: params.packingEfficiency,
VERTICAL_RATIO: params.verticalRatio,
TARGET_FILL_RATIO: params.targetFillRatio,
MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx,
WORK_SCALE: params.workScale,
@@ -487,11 +509,23 @@ export default function TestWorkbench({
sse.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
const localElapsed = requestStartedAtRef.current == null
? undefined
: (performance.now() - requestStartedAtRef.current) / 1000;
setProgress({
stage: msg.stage ?? '',
percent: msg.progress_percent ?? 0,
message: msg.message ?? '',
elapsedSeconds: typeof msg.elapsed_seconds === 'number' ? msg.elapsed_seconds : undefined,
clientElapsedSeconds: localElapsed,
});
if (msg.message) {
setLogLines(prev => [...prev, msg.message]);
}
if (msg.stage === 'preview_ready' && previewRequestedJobRef.current !== id) {
previewRequestedJobRef.current = id;
fetchPreview(id);
}
if (msg.progress_percent >= 100 || msg.stage === 'completed' || msg.stage === 'failed') {
sse.close();
fetchResult(id);
@@ -512,12 +546,48 @@ export default function TestWorkbench({
}
};
const fetchPreview = async (id: string) => {
try {
const res = await fetch(apiUrl(`/api/jobs/${id}/result`));
if (!res.ok) return;
const data: JobResult = await res.json();
if (!data.image_url || data.status === 'failed') return;
const imageUrl = data.image_url || `/api/jobs/${id}/files/png`;
const elapsed = requestStartedAtRef.current == null
? undefined
: (performance.now() - requestStartedAtRef.current) / 1000;
setClientElapsedSeconds(elapsed ?? null);
setJobResult(prev => {
// The final request may win the race with this preview fetch. Never
// downgrade a completed result back to the partial running payload.
if (prev?.status === 'success') return prev;
return { ...prev, ...data, image_url: imageUrl };
});
setProgress(prev => {
if (prev?.stage === '完成' || prev?.stage === '生成失败') return prev;
return {
stage: 'preview_ready',
percent: Math.max(prev?.percent ?? 0, 94),
message: '预览已显示,后台继续导出其余文件',
elapsedSeconds: prev?.elapsedSeconds,
clientElapsedSeconds: elapsed,
};
});
} catch {
// The final result request remains the source of truth if preview fetch fails.
}
};
// ─── 获取结果 ─────────────────────────────────────────────────────────────
// GET /api/jobs/{job_id}/result
const fetchResult = async (id: string) => {
try {
const res = await ensureOk(await fetch(apiUrl(`/api/jobs/${id}/result`)), '获取结果失败');
const data: JobResult = await res.json();
const resultElapsed = requestStartedAtRef.current == null
? null
: (performance.now() - requestStartedAtRef.current) / 1000;
setClientElapsedSeconds(resultElapsed);
if (data.status === 'failed') {
// 尝试从 /detail 拿更详细的错误信息
@@ -526,7 +596,8 @@ export default function TestWorkbench({
const dr = await fetch(apiUrl(`/api/jobs/${id}/detail`));
if (dr.ok) {
const dd = await dr.json();
detail = dd.error ?? dd.message ?? '';
// API 返回 { status: { error: "...", ... }, recent_events: [...] }
detail = dd.status?.error ?? '';
} else {
detail = await readApiError(dr);
}
@@ -534,7 +605,11 @@ export default function TestWorkbench({
setProgress({
stage: '生成失败',
percent: 0,
message: `任务失败${detail ? '' + detail : ''}。可用 docker logs 查看后端堆栈。`,
message: detail
? `任务失败:\n${detail}`
: '任务失败。可用 docker logs 查看后端堆栈。',
elapsedSeconds: typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined,
clientElapsedSeconds: resultElapsed ?? undefined,
});
setIsGenerating(false);
return;
@@ -546,8 +621,17 @@ export default function TestWorkbench({
const svgUrl = data.svg_url || `/api/jobs/${id}/files/svg`;
setJobResult({ ...data, image_url: imageUrl, svg_url: svgUrl });
setProgress({ stage: '完成', percent: 100, message: '词云生成完成!' });
setTimeout(() => setProgress(null), 3000);
const backendElapsed = typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined;
const visibleElapsed = displayElapsedRef.current ?? resultElapsed ?? undefined;
setProgress({
stage: '完成',
percent: 100,
message: backendElapsed != null
? `词云生成完成,用时 ${backendElapsed.toFixed(2)}${visibleElapsed != null ? `,前端显示 ${visibleElapsed.toFixed(2)} 秒(QoS判断根据)` : ''}`
: '词云生成完成!',
elapsedSeconds: backendElapsed,
clientElapsedSeconds: visibleElapsed,
});
} catch (err) {
const msg = err instanceof Error ? err.message : '未知错误';
setProgress({ stage: '错误', percent: 0, message: msg });
@@ -556,6 +640,28 @@ export default function TestWorkbench({
}
};
const handleImageLoaded = () => {
const currentJobId = jobResult?.job_id;
if (!currentJobId) return;
const isFinal = progress?.stage === '完成';
const displayKey = `${currentJobId}:${isFinal ? 'final' : 'preview'}`;
if (displayedJobRef.current === displayKey) return;
displayedJobRef.current = displayKey;
if (requestStartedAtRef.current == null) return;
const elapsed = (performance.now() - requestStartedAtRef.current) / 1000;
displayElapsedRef.current = elapsed;
setClientElapsedSeconds(elapsed);
setProgress(prev => prev ? {
...prev,
clientElapsedSeconds: elapsed,
message: isFinal
? (prev.elapsedSeconds != null
? `词云生成完成,用时 ${prev.elapsedSeconds.toFixed(2)} 秒,前端显示 ${elapsed.toFixed(2)}`
: `词云生成完成,前端显示 ${elapsed.toFixed(2)}`)
: `预览已显示,前端耗时 ${elapsed.toFixed(2)}`,
} : prev);
};
const handleLocate = (loc: NameLocation) => {
setHighlightLocation(loc);
setViewMode('2d');
@@ -803,9 +909,10 @@ export default function TestWorkbench({
viewMode={viewMode}
zoom={zoom}
highlightLocation={highlightLocation}
onImageLoaded={handleImageLoaded}
/>
<ProgressPanel progress={progress} visible={isGenerating || !!progress} />
<ProgressPanel progress={progress} logLines={logLines} visible={isGenerating || !!progress} />
<ViewControls
zoom={zoom}
+83 -2
View File
@@ -1640,8 +1640,90 @@ body.resizing {
}
.layer-row {
grid-template-columns: 56px minmax(0, 1fr);
gap: 8px;
padding: 6px;
align-items: start;
}
.layer-info {
display: grid;
gap: 5px;
padding: 5px;
min-width: 0;
}
.layer-thumb-wrap {
position: relative;
width: 56px;
height: 56px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
}
.layer-thumb {
position: absolute;
inset: 0;
overflow: hidden;
background:
linear-gradient(45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
}
.layer-thumb-hidden {
opacity: 0.45;
}
.layer-thumb-scale {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
overflow: hidden;
}
.layer-thumb-doc {
position: relative;
transform-origin: 0 0;
pointer-events: none;
}
.layer-thumb-image,
.layer-thumb-text,
.layer-thumb-shape,
.layer-thumb-line {
position: relative;
pointer-events: none;
user-select: none;
box-sizing: border-box;
}
.layer-thumb-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.layer-thumb-text {
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.1;
}
.layer-thumb-line {
display: flex;
align-items: center;
}
.layer-thumb-ellipse {
border-radius: 50%;
}
.layer-row.active {
@@ -1667,7 +1749,6 @@ body.resizing {
.layer-sub-line {
grid-template-columns: minmax(0, 1fr) auto;
padding-left: 49px;
}
.layer-action-group {
+7
View File
@@ -18,10 +18,13 @@ export interface JobParams {
fontColor: string;
nRepetitions: number;
strokeWeights: boolean;
autoRepeatToFill: boolean;
saveDebugImages: boolean;
// 字号与填充
sizeRatio: number;
packingEfficiency: number;
verticalRatio: number;
targetFillRatio: number;
userMinFontSize: number | null;
userMaxFontSize: number | null;
@@ -47,6 +50,7 @@ export interface JobResult {
svg_stroke_url: string;
db_url: string;
metrics_url: string;
elapsed_seconds?: number | null;
}
export interface NameLocation {
@@ -71,6 +75,8 @@ export interface SSEProgress {
stage: string;
percent: number;
message: string;
elapsedSeconds?: number;
clientElapsedSeconds?: number;
}
export type PanelType = 'import' | 'export' | 'edit' | 'find' | 'advanced' | null;
@@ -104,6 +110,7 @@ export interface StickerAsset {
source: string;
createdAt: string;
tint?: string;
mimeType?: string;
}
export type CanvasElementType = 'sticker' | 'text' | 'rect' | 'ellipse' | 'line';