-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathphoto_magazine_generator.py
More file actions
2000 lines (1660 loc) · 94.2 KB
/
photo_magazine_generator.py
File metadata and controls
2000 lines (1660 loc) · 94.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import json
import os
from PIL import Image
import torch
import numpy as np
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.lib.colors import HexColor
from reportlab.pdfbase import pdfutils
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase.pdfmetrics import registerFont, stringWidth
class PhotoMagazinePromptGenerator:
"""
寫真雜誌prompt生成器
從 prompts 資料夾讀取模板,注入使用者參數後輸出給 LLM
"""
def __init__(self):
pass
def tensor_to_pil(self, tensor):
"""轉換tensor為PIL圖片"""
if isinstance(tensor, torch.Tensor):
tensor = tensor.squeeze()
if tensor.dim() == 3:
if tensor.shape[0] in [1, 3, 4]: # CHW
tensor = tensor.permute(1, 2, 0)
if tensor.shape[2] == 1: # 灰階
tensor = tensor.repeat(1, 1, 3)
elif tensor.shape[2] == 4: # RGBA
tensor = tensor[:, :, :3]
if tensor.max() <= 1.0:
tensor = tensor * 255
numpy_image = tensor.cpu().numpy().astype(np.uint8)
return Image.fromarray(numpy_image)
return tensor
@classmethod
def INPUT_TYPES(cls):
# 獲取 DesignPrompt 資料夾中的模板列表
design_prompt_dir = os.path.join(os.path.dirname(__file__), "DesignPrompt")
template_files = ["photomagazine_json_output.md"] # 預設模板
if os.path.exists(design_prompt_dir):
# 讀取所有 .md 檔案
md_files = [f for f in os.listdir(design_prompt_dir) if f.endswith('.md')]
if md_files:
template_files = md_files
return {
"required": {
"template": (template_files, {"default": template_files[0] if template_files else "photomagazine_json_output.md"}),
"model_name": ("STRING", {"default": "", "placeholder": "模特兒名稱(例如:小美、Lisa)"}),
"photo_style": ("STRING", {"default": "自然清新", "placeholder": "拍攝風格(自由輸入)"}),
"custom_scene": ("STRING", {"default": "", "placeholder": "場景設定(可選)"}),
"content_pages": ("INT", {"default": 8, "min": 3, "max": 30, "step": 1}),
"features": ("STRING", {"default": "", "placeholder": "人物特徵(可選,有圖片時自動提取)"}),
},
"optional": {
"reference_image": ("IMAGE",), # 可選的參考圖片(暫時保留,未來可能移除)
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "generate_prompt"
CATEGORY = "DesignPack"
def extract_person_features(self, image_tensor):
"""從圖片中提取人物特徵"""
try:
# 轉換 tensor 為 PIL 圖片
pil_image = self.tensor_to_pil(image_tensor)
# 構建人物特徵提取prompt
feature_prompt = """請分析這images中的人物,詳細描述以下Features:
1. 國籍/種族特徵
2. 臉型(圓臉、瓜子臉、方臉等)
3. 五官特徵(眼睛、鼻子、嘴巴)
4. 妝容風格
5. 髮型和髮色
6. 其他明顯特徵(眼鏡、飾品等)
請用簡潔的中文描述,約50-80字。"""
# 這裡需要調用 LLM 來分析圖片
# 由於我們在節點中,可以返回一個提示讓使用者知道需要連接 LLM
print("📸 Reference image detected, recommend using LLM node to extract person features")
print(" 提示:可以先用 Image to Prompt 節點分析圖片")
# 返回基本的視覺描述(不依賴 LLM)
return "根據參考圖片的人物特徵"
except Exception as e:
print(f"圖片特徵提取錯誤: {e}")
return ""
def generate_prompt(self, template, model_name, photo_style, custom_scene, content_pages, features, reference_image=None):
"""讀取模板並注入參數"""
try:
# 如果有參考圖片,自動使用 {EXTRACT_FROM_IMAGE} 佔位符
if reference_image is not None:
print("📸 檢測到參考圖片,自動使用 {EXTRACT_FROM_IMAGE} 佔位符")
features = "{EXTRACT_FROM_IMAGE}"
print(" 提示:請在 LLM 節點中:")
print(" 1. 連接此參考圖片")
print(" 2. 載入 Prompt/extract_person_features.md")
print(" LLM 會自動提取人物特徵並替換佔位符")
# 讀取模板檔案(從 DesignPrompt 資料夾)
template_path = os.path.join(os.path.dirname(__file__), "DesignPrompt", template)
if not os.path.exists(template_path):
return (f"Error:找不到模板檔案 {template_path}",)
with open(template_path, 'r', encoding='utf-8') as f:
template_content = f.read()
# 準備人物特徵描述
if features == "{EXTRACT_FROM_IMAGE}":
features_description = "人物Features:{EXTRACT_FROM_IMAGE}"
elif features:
features_description = f"人物Features:{features}"
else:
features_description = "人物Features:根據模特兒名稱自行判斷"
# 注入參數
prompt = template_content.format(
model_name=model_name,
features=features if features else "根據模特兒名稱自行判斷",
photo_style=photo_style,
custom_scene=custom_scene if custom_scene else "自動判定",
content_pages=content_pages,
features_description=features_description
)
print("📝 prompt生成完成")
print(f" Template:{template}")
print(f" Model:{model_name}")
print(f" Features:{features if features else '自動判定'}")
if reference_image is not None:
print(f" 參考圖片:✅ 已提供(將自動提取特徵)")
print(f" Style:{photo_style}")
print(f" Scene:{custom_scene if custom_scene else '自動判定'}")
print(f" Pages:{content_pages}")
print("💡 請連接到 LLM 節點")
return (prompt,)
except Exception as e:
import traceback
error_msg = f"Error:{str(e)}\n{traceback.format_exc()}"
print(error_msg)
return (error_msg,)
class PhotoMagazineParser:
"""
寫真雜誌解析器 - 極簡版
輸入:LLM 輸出的 JSON 字串(STRING 接口)
輸出:prompt列表(LIST[STRING])
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"llm_json_output": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompts",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "parse"
CATEGORY = "DesignPack"
def parse(self, llm_json_output):
"""解析 JSON,提取圖片prompt列表"""
try:
if not llm_json_output or not llm_json_output.strip():
return (["Error: JSON input is empty"],)
print("📝 Starting to parse LLM JSON output...")
image_prompts = self.extract_prompts(llm_json_output.strip())
print(f"✅ Parsing complete! Extracted {len(image_prompts)} image prompts")
return (image_prompts,)
except Exception as e:
import traceback
error_msg = f"Error:{str(e)}\n{traceback.format_exc()}"
print(error_msg)
return ([error_msg],)
def extract_prompts(self, json_text):
"""從 JSON 文字中提取所有圖片prompt"""
try:
# 清理 JSON 文字(移除 markdown 代碼塊標記)
if json_text.startswith("```json"):
json_text = json_text[7:]
if json_text.startswith("```"):
json_text = json_text[3:]
if json_text.endswith("```"):
json_text = json_text[:-3]
json_text = json_text.strip()
# 嘗試解析 JSON
try:
data = json.loads(json_text)
except json.JSONDecodeError:
# 如果解析失敗,嘗試提取 JSON 部分
start_idx = json_text.find('{')
end_idx = json_text.rfind('}') + 1
if start_idx != -1 and end_idx != 0:
json_str = json_text[start_idx:end_idx]
data = json.loads(json_str)
else:
return [f"Error:無法解析 JSON\n內容:{json_text[:200]}..."]
# 提取所有 image_prompt
prompts = []
# 1. 封面prompt
if "cover" in data and isinstance(data["cover"], dict):
prompt = data["cover"].get("image_prompt", "")
if prompt:
prompts.append(str(prompt).strip())
print(f" ✓ Cover prompt")
# 2. 內容pageprompt
if "pages" in data and isinstance(data["pages"], list):
for i, page in enumerate(data["pages"]):
if isinstance(page, dict):
prompt = page.get("image_prompt", "")
if prompt:
prompts.append(str(prompt).strip())
print(f" ✓ Page {i+1} prompt")
# 3. 故事pageprompt
if "story_page" in data and isinstance(data["story_page"], dict):
prompt = data["story_page"].get("image_prompt", "")
if prompt:
prompts.append(str(prompt).strip())
print(f" ✓ Story page prompt")
if not prompts:
return ["Warning: No image_prompt found"]
return prompts
except Exception as e:
import traceback
return [f"Parse error:{str(e)}\n{traceback.format_exc()}"]
def clean_markdown_content(self, data):
"""清理JSON內容中的markdown標記和符號"""
import re
def clean_text(text):
if not isinstance(text, str):
return text
# 移除markdown標記
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) # 粗體 **text**
text = re.sub(r'\*(.*?)\*', r'\1', text) # 斜體 *text*
text = re.sub(r'__(.*?)__', r'\1', text) # 粗體 __text__
text = re.sub(r'_(.*?)_', r'\1', text) # 斜體 _text_
text = re.sub(r'`(.*?)`', r'\1', text) # 程式碼 `code`
text = re.sub(r'#+\s*', '', text) # 標題 # ## ###
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) # 連結 [text](url)
text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'\1', text) # 圖片 
text = re.sub(r'^\>\s*', '', text, flags=re.MULTILINE) # 引用 >
text = re.sub(r'^\s*[-\*\+]\s*', '', text, flags=re.MULTILINE) # 列表項目
text = re.sub(r'^\s*\d+\.\s*', '', text, flags=re.MULTILINE) # 數字列表
# 清理多餘的空白和換行
text = re.sub(r'\n\s*\n', '\n', text) # 多個換行變成單個
text = text.strip()
return text
def clean_recursive(obj):
if isinstance(obj, dict):
return {key: clean_recursive(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [clean_recursive(item) for item in obj]
elif isinstance(obj, str):
return clean_text(obj)
else:
return obj
return clean_recursive(data)
def parse_response_and_generate_prompts(self, response_text):
"""解析回應並生成圖片prompt"""
try:
# 解析回應並提取JSON
if response_text.startswith("```json"):
response_text = response_text[7:]
if response_text.endswith("```"):
response_text = response_text[:-3]
response_text = response_text.strip()
# 嘗試解析JSON
try:
magazine_data = json.loads(response_text)
except json.JSONDecodeError:
# 如果JSON解析失敗,嘗試提取JSON部分
start_idx = response_text.find('{')
end_idx = response_text.rfind('}') + 1
if start_idx != -1 and end_idx != 0:
json_str = response_text[start_idx:end_idx]
magazine_data = json.loads(json_str)
else:
return ([f"Error:無法解析為JSON\n回應內容:{response_text[:500]}..."], "")
# 清理markdown標記
magazine_data = self.clean_markdown_content(magazine_data)
# 提取圖片prompt列表 - 從完整JSON中獲取所有prompts
image_prompts = []
def extract_prompt(data, key="image_prompt", fallback="portrait photography, professional model"):
"""提取單個prompt的輔助函數"""
if isinstance(data, dict):
prompt = data.get(key, "")
if isinstance(prompt, str) and prompt.strip():
return prompt.strip()
elif isinstance(prompt, dict):
if "prompt" in prompt:
return str(prompt["prompt"])
else:
for value in prompt.values():
if isinstance(value, str) and value.strip():
return value
return fallback
else:
return str(prompt) if prompt else fallback
return fallback
# 1. 封面 image_prompt
cover_data = magazine_data.get("cover", {})
if cover_data:
cover_prompt = extract_prompt(cover_data)
image_prompts.append(cover_prompt)
print(f" 封面 prompt: {cover_prompt[:50]}...")
# 2. 從 pages 中提取每page的 image_prompt
pages = magazine_data.get("pages", [])
for i, page in enumerate(pages):
page_prompt = extract_prompt(page)
image_prompts.append(page_prompt)
print(f" page面 {i+1} prompt: {page_prompt[:50]}...")
# 3. 故事page image_prompt
story_data = magazine_data.get("story_page", {})
if story_data:
story_prompt = extract_prompt(story_data)
image_prompts.append(story_prompt)
print(f" 故事page prompt: {story_prompt[:50]}...")
# 返回完整的 JSON 字串
json_string = json.dumps(magazine_data, ensure_ascii=False, indent=2)
return (image_prompts, json_string)
except Exception as e:
return ([f"Error:{str(e)}"], "")
class PhotoMagazineMaker:
@classmethod
def INPUT_TYPES(cls):
# 獲取字體檔案列表
font_dir = os.path.join(os.path.dirname(__file__), "fonts")
font_files = ["default"]
if os.path.exists(font_dir):
font_files.extend([f for f in os.listdir(font_dir) if f.endswith(('.ttf', '.ttc', '.otf'))])
return {
"required": {
"images": ("IMAGE",),
"json_data": ("STRING",),
"template": (["清新自然", "時尚都市", "復古經典"], {"default": "清新自然"}),
"layout": (["版型A-經典排版", "版型B-藝術拼貼", "版型C-簡約現代"], {"default": "版型A-經典排版"}),
"font": (font_files, {"default": font_files[0] if font_files else "default"}),
"compress_pdf": ("BOOLEAN", {"default": False}),
"disable_cover_layout": ("BOOLEAN", {
"default": False,
"tooltip": "關閉封面排版,使用Page一images作為滿版封面(不含文字)"
}),
"output_path": ("STRING", {"default": "./ComfyUI/output/MyPDF/photo_magazine.pdf"}),
}
}
INPUT_IS_LIST = (True, False, False, False, False, False, False, False) # 只有images是列表
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("result",)
FUNCTION = "make_photo_magazine"
CATEGORY = "DesignPack"
def __init__(self):
self.template_configs = {
"清新自然": {
"primary": "#4A90A4", # 清新藍
"secondary": "#7FB069", # 自然綠
"accent": "#F7E7CE", # 米白
"text": "#2C3E50", # 深灰
"background": "#FFFFFF" # 白色
},
"時尚都市": {
"primary": "#2C3E50", # 深藍灰
"secondary": "#E74C3C", # 時尚紅
"accent": "#F8F9FA", # 淺灰
"text": "#34495E", # 灰藍
"background": "#FFFFFF" # 白色
},
"復古經典": {
"primary": "#8B4513", # 復古棕
"secondary": "#DAA520", # 金黃
"accent": "#F5F5DC", # 米色
"text": "#654321", # 深棕
"background": "#FFF8DC" # 古典白
}
}
def tensor_to_pil(self, tensor):
"""轉換tensor為PIL圖片,帶記憶體優化"""
try:
if isinstance(tensor, torch.Tensor):
tensor = tensor.squeeze()
if tensor.dim() == 3:
if tensor.shape[0] in [1, 3, 4]: # CHW
tensor = tensor.permute(1, 2, 0)
if tensor.shape[2] == 1: # 灰階
tensor = tensor.repeat(1, 1, 3)
elif tensor.shape[2] == 4: # RGBA
tensor = tensor[:, :, :3]
if tensor.max() <= 1.0:
tensor = tensor * 255
numpy_image = tensor.cpu().numpy().astype(np.uint8)
pil_image = Image.fromarray(numpy_image)
# 記憶體優化:限制圖片大小
max_size = (2048, 2048)
if pil_image.size[0] > max_size[0] or pil_image.size[1] > max_size[1]:
pil_image = pil_image.resize(max_size, Image.Resampling.LANCZOS)
return pil_image
return tensor
except Exception as e:
# 備用方案:建立空白圖片
print(f"Image conversion error: {e}")
return Image.new('RGB', (800, 600), color='white')
def resize_image_to_fit(self, pil_image, max_width_mm, max_height_mm, dpi=300):
"""調整圖片大小以符合指定的毫米尺寸"""
try:
# 轉換毫米到像素
max_width_px = int(max_width_mm * dpi / 25.4)
max_height_px = int(max_height_mm * dpi / 25.4)
# 計算縮放比例
width_ratio = max_width_px / pil_image.width
height_ratio = max_height_px / pil_image.height
scale_ratio = min(width_ratio, height_ratio)
# 調整大小
new_width = int(pil_image.width * scale_ratio)
new_height = int(pil_image.height * scale_ratio)
resized_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
return resized_image
except Exception as e:
print(f"圖片調整大小錯誤: {e}")
return pil_image
def register_font(self, font_name):
"""註冊字體"""
try:
if font_name != "default":
font_path = os.path.join(os.path.dirname(__file__), "fonts", font_name)
if os.path.exists(font_path):
registerFont(TTFont("CustomFont", font_path))
return "CustomFont"
return "Helvetica"
except:
return "Helvetica"
def get_fallback_image(self, all_images, used_indices, preferred_index=None):
"""從圖片清單中選擇一張未使用的圖片作為底圖"""
if not all_images:
return None
# 如果指定了偏好索引且可用,優先使用
if preferred_index is not None and preferred_index < len(all_images) and preferred_index not in used_indices:
return all_images[preferred_index]
# 否則找Page一張未使用的圖片
for i, img in enumerate(all_images):
if i not in used_indices:
return img
# 如果所有圖片都用過了,隨機選一張
import random
return random.choice(all_images)
def create_full_bleed_image(self, pil_image, width=210*mm, height=297*mm):
"""創建滿版圖片"""
try:
# 計算目標比例
target_ratio = width / height
image_ratio = pil_image.width / pil_image.height
if image_ratio > target_ratio:
# 圖片較寬,按高度縮放
new_height = int(height * 300 / 25.4) # 轉為像素
new_width = int(new_height * image_ratio)
else:
# 圖片較高,按寬度縮放
new_width = int(width * 300 / 25.4) # 轉為像素
new_height = int(new_width / image_ratio)
# 調整圖片大小
resized_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
# 裁切到目標尺寸
target_width_px = int(width * 300 / 25.4)
target_height_px = int(height * 300 / 25.4)
left = (new_width - target_width_px) // 2
top = (new_height - target_height_px) // 2
right = left + target_width_px
bottom = top + target_height_px
cropped_image = resized_image.crop((left, top, right, bottom))
return cropped_image
except Exception as e:
print(f"Full bleed image creation error: {e}")
return pil_image
def wrap_text(self, text, max_width_mm, font_name, font_size, canvas_obj):
"""文字換行處理 - 基於實際字符寬度"""
lines = []
# 確保 text 是字符串格式
if isinstance(text, list):
text = " ".join(str(item) for item in text)
elif not isinstance(text, str):
text = str(text)
# 按句號和換行符分段
paragraphs = text.replace('\n', '。').split('。')
for paragraph in paragraphs:
if not paragraph.strip():
continue
paragraph = paragraph.strip() + '。' if not paragraph.endswith('。') else paragraph.strip()
words = list(paragraph) # 中文按字符分割
current_line = ""
for char in words:
test_line = current_line + char
# 計算實際字符寬度
line_width = stringWidth(test_line, font_name, font_size)
if line_width <= max_width_mm * mm:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
# 段落間空行
if paragraph != paragraphs[-1]:
lines.append("")
return lines
def has_valid_cover_content(self, cover_info):
"""檢查封面是否有有效的文字內容(非空且非None)"""
if not isinstance(cover_info, dict):
return False
# 檢查主要文字欄位
title = cover_info.get("title", "").strip() if cover_info.get("title") else ""
subtitle = cover_info.get("subtitle", "").strip() if cover_info.get("subtitle") else ""
description = cover_info.get("description", "").strip() if cover_info.get("description") else ""
# 如果有任何一個有效的文字內容,就認為有內容
return bool(title or subtitle or description)
def allocate_images_smartly(self, images, num_content_pages):
"""
智能分配圖片到各pages
返回一個字典,包含各部分應使用的圖片索引
"""
total_images = len(images)
# 計算所需圖片數量
# 封面: 1, 內容page: num_content_pages, 故事page: 1, 尾page: 至少1張(最多4張用於拼貼)
min_needed = 1 + num_content_pages + 1 + 1 # 最少需求
allocation = {
"cover": None,
"pages": [],
"story": None,
"footer": []
}
if total_images == 0:
print("警告:沒有圖片可分配")
return allocation
idx = 0
# 1. 封面(優先)
if idx < total_images:
allocation["cover"] = idx
idx += 1
# 2. 內容page
for i in range(num_content_pages):
if idx < total_images:
allocation["pages"].append(idx)
idx += 1
else:
# 圖片不足,重複使用
allocation["pages"].append(i % total_images)
# 3. 故事page
if idx < total_images:
allocation["story"] = idx
idx += 1
else:
# 重複使用Page一張
allocation["story"] = 0
# 4. 尾page(收集剩餘圖片,最多4張用於四分割)
remaining_images = total_images - idx
if remaining_images > 0:
# 使用剩餘的圖片
for i in range(min(remaining_images, 4)):
allocation["footer"].append(idx + i)
else:
# 沒有剩餘圖片,使用前面的圖片
# 優先使用內容page的圖片as back cover拼貼
if len(allocation["pages"]) >= 4:
# 如果有足夠的內容page圖片,取最後4張
allocation["footer"] = allocation["pages"][-4:]
elif len(allocation["pages"]) > 0:
# 圖片不足4張,重複使用
for i in range(4):
allocation["footer"].append(allocation["pages"][i % len(allocation["pages"])])
else:
# 只有封面圖,重複使用封面
allocation["footer"] = [0, 0, 0, 0]
return allocation
def draw_cover_page(self, canvas_obj, magazine_data, cover_image, template_config, font_name, layout, compress_enabled=False):
"""繪製封面"""
try:
cover_info = magazine_data.get("cover", {})
# 確保 cover_info 是字典類型
if not isinstance(cover_info, dict):
print(f"警告:cover_info 不是字典類型(類型: {type(cover_info)}), using default values")
cover_info = {}
print(f"封面數據: {cover_info}")
# 檢查是否有有效的封面內容,如果沒有則僅顯示圖片
has_content = self.has_valid_cover_content(cover_info)
print(f"封面是否有有效內容: {has_content}")
if not has_content and cover_image:
# 純圖片封面:滿版顯示Page一images,不添加任何文字
print("使用純圖片封面模式")
full_bleed = self.create_full_bleed_image(cover_image)
import time
temp_path = f"temp_cover_simple_{int(time.time() * 1000000)}.jpg"
quality = self.get_image_quality(compress_enabled)
full_bleed.save(temp_path, "JPEG", quality=quality)
canvas_obj.drawImage(temp_path, 0, 0, width=210*mm, height=297*mm)
try:
os.remove(temp_path)
except:
pass
return
if layout == "版型A-經典排版":
# 經典排版:上方滿版圖片 + 下方文字區域
if cover_image:
# 上方滿版圖片(佔據上半部)
full_bleed_top = self.create_full_bleed_image(cover_image, width=210*mm, height=200*mm)
import time
temp_path = f"temp_cover_{int(time.time() * 1000000)}.jpg"
quality = self.get_image_quality(compress_enabled)
full_bleed_top.save(temp_path, "JPEG", quality=quality)
# 滿版圖片(上方)
canvas_obj.drawImage(temp_path, 0, 97*mm, width=210*mm, height=200*mm)
try:
os.remove(temp_path)
except:
pass
else:
# 沒有圖片時的上方背景
canvas_obj.setFillColor(HexColor(template_config["secondary"]))
canvas_obj.rect(0, 97*mm, 210*mm, 200*mm, fill=1, stroke=0)
# 下方文字區域背景
canvas_obj.setFillColor(HexColor(template_config["background"]))
canvas_obj.rect(0, 0, 210*mm, 97*mm, fill=1, stroke=0)
# 半透明文字框覆蓋在圖片下方
canvas_obj.setFillColor(HexColor("#000000"))
canvas_obj.setFillAlpha(0.7)
canvas_obj.rect(10*mm, 10*mm, 190*mm, 80*mm, fill=1, stroke=0)
canvas_obj.setFillAlpha(1)
# 主標題
canvas_obj.setFont(font_name, 28)
canvas_obj.setFillColor(HexColor("#FFFFFF"))
title = cover_info.get("title", "").strip() if cover_info.get("title") else ""
if not title:
title = "寫真集" # 預設標題
title_width = stringWidth(title, font_name, 28)
canvas_obj.drawString((210*mm - title_width) / 2, 65*mm, title)
# 副標題
canvas_obj.setFont(font_name, 16)
canvas_obj.setFillColor(HexColor("#E0E0E0"))
subtitle = cover_info.get("subtitle", "").strip() if cover_info.get("subtitle") else ""
if subtitle:
subtitle_width = stringWidth(subtitle, font_name, 16)
canvas_obj.drawString((210*mm - subtitle_width) / 2, 45*mm, subtitle)
# 描述文案(自動換行)
canvas_obj.setFont(font_name, 12)
canvas_obj.setFillColor(HexColor("#CCCCCC"))
description = cover_info.get("description", "").strip() if cover_info.get("description") else ""
if description:
lines = self.wrap_text(description, 170, font_name, 12, canvas_obj)
y_pos = 30*mm
for line in lines[:3]: # 最多3行
if line.strip():
line_width = stringWidth(line, font_name, 12)
canvas_obj.drawString((210*mm - line_width) / 2, y_pos, line)
y_pos -= 6
elif layout == "版型B-藝術拼貼":
# 版型B:滿版圖片 + 粗體文字直接壓在底圖上(取消透明框)
if cover_image:
full_bleed = self.create_full_bleed_image(cover_image)
import time
temp_path = f"temp_cover_b_{int(time.time() * 1000000)}.jpg"
full_bleed.save(temp_path, "JPEG", quality=95)
canvas_obj.drawImage(temp_path, 0, 0, width=210*mm, height=297*mm)
try:
os.remove(temp_path)
except:
pass
# 主標題(左上,無背景框,放大字型)
title = cover_info.get("title", "").strip() if cover_info.get("title") else ""
if title:
canvas_obj.setFont(font_name, 32) # 放大標題
canvas_obj.setFillColor(HexColor("#FFFFFF"))
canvas_obj.setStrokeColor(HexColor("#000000"))
canvas_obj.setLineWidth(1.0)
canvas_obj.drawString(25*mm, 245*mm, title)
# 副標題(右下,無背景框,放大字型)
subtitle = cover_info.get("subtitle", "").strip() if cover_info.get("subtitle") else ""
if subtitle:
canvas_obj.setFont(font_name, 18) # 放大副標題
canvas_obj.setFillColor(HexColor("#FFFFFF"))
canvas_obj.setStrokeColor(HexColor("#000000"))
canvas_obj.setLineWidth(0.8)
subtitle_width = stringWidth(subtitle, font_name, 18)
canvas_obj.drawString(210*mm - subtitle_width - 15*mm, 35*mm, subtitle)
# 描述文案(中心左側,無背景框,放大字型)
description = cover_info.get("description", "").strip() if cover_info.get("description") else ""
if description:
canvas_obj.setFont(font_name, 14) # 放大描述文字
canvas_obj.setFillColor(HexColor("#FFFFFF"))
canvas_obj.setStrokeColor(HexColor("#000000"))
canvas_obj.setLineWidth(0.6)
lines = self.wrap_text(description, 110, font_name, 14, canvas_obj)
y_position = 150*mm
line_height = 16
for line in lines[:4]: # 最多顯示4行
if y_position < 100*mm:
break
if line.strip():
canvas_obj.drawString(20*mm, y_position, line)
y_position -= line_height
elif layout == "版型C-簡約現代":
# 現代簡約:非對稱布局,左側裁切圖片 + 右側文字
# 根據文字內容計算適當的版面寬度
all_text_content = ""
if cover_info.get("title"):
all_text_content += cover_info.get("title")
if cover_info.get("subtitle"):
all_text_content += cover_info.get("subtitle")
if cover_info.get("description"):
all_text_content += cover_info.get("description")
text_area_width = self.calculate_adaptive_layout_width(all_text_content, 70)
image_area_width = 210 - text_area_width # 剩餘空間給圖片
# 背景色
canvas_obj.setFillColor(HexColor(template_config["accent"]))
canvas_obj.rect(0, 0, 210*mm, 297*mm, fill=1, stroke=0)
# 左側圖片區域(適應性寬度)- 按比例裁切
if cover_image:
# 目標區域:動態寬度 x 297mm (全高)
target_width = image_area_width
target_height = 297
target_ratio = target_width / target_height
# 原圖比例
img_ratio = cover_image.width / cover_image.height
if img_ratio > target_ratio:
# 圖片太寬,需要裁切寬度
new_height = cover_image.height
new_width = int(new_height * target_ratio)
left = (cover_image.width - new_width) // 2
cropped_image = cover_image.crop((left, 0, left + new_width, new_height))
else:
# 圖片太高,需要裁切高度
new_width = cover_image.width
new_height = int(new_width / target_ratio)
top = (cover_image.height - new_height) // 2
cropped_image = cover_image.crop((0, top, new_width, top + new_height))
# 調整到目標尺寸
target_px_w = int(target_width * 300 / 25.4) # mm轉像素
target_px_h = int(target_height * 300 / 25.4)
resized_image = cropped_image.resize((target_px_w, target_px_h), Image.Resampling.LANCZOS)
import time
temp_path = f"temp_cover_{int(time.time() * 1000000)}.jpg"
resized_image.save(temp_path, "JPEG", quality=95)
canvas_obj.drawImage(temp_path, 0, 0, width=image_area_width*mm, height=297*mm)
try:
os.remove(temp_path)
except:
pass
else:
# 沒有圖片時的左側背景
canvas_obj.setFillColor(HexColor(template_config["secondary"]))
canvas_obj.rect(0, 0, image_area_width*mm, 297*mm, fill=1, stroke=0)
# 右側文字區域(垂直排列)
canvas_obj.setFillColor(HexColor(template_config["background"]))
canvas_obj.rect(image_area_width*mm, 0, text_area_width*mm, 297*mm, fill=1, stroke=0)
# 垂直文字排列
canvas_obj.setFont(font_name, 28)
canvas_obj.setFillColor(HexColor(template_config["primary"]))
title = cover_info.get("title", "").strip() if cover_info.get("title") else ""
if not title:
title = "寫真集" # 預設標題
# 計算文字區域中心位置
text_center_x = image_area_width + text_area_width / 2
# 從上到下繪製標題字元
y_pos = 250*mm
for char in title:
char_width = stringWidth(char, font_name, 28)
canvas_obj.drawString(text_center_x*mm - char_width/2, y_pos, char)
y_pos -= 35
# 副標題(水平)- 調整位置避免與 description 重疊
canvas_obj.setFont(font_name, 16)
canvas_obj.setFillColor(HexColor(template_config["secondary"]))
subtitle = cover_info.get("subtitle", "").strip() if cover_info.get("subtitle") else ""
if subtitle:
# 計算 subtitle 需要的行數
available_width = text_area_width - 10
subtitle_lines = self.wrap_text(subtitle, available_width, font_name, 16, canvas_obj)
subtitle_height = len(subtitle_lines) * 20 # 每行約 20
# 從較高位置開始,為 description 留出空間
subtitle_y = 120*mm
for line in subtitle_lines[:3]: # 最多 3 行
if line.strip():
line_width = stringWidth(line, font_name, 16)
canvas_obj.drawString(text_center_x*mm - line_width/2, subtitle_y, line)
subtitle_y -= 20
# 描述文案(水平,自動換行)- 調整起始位置
description = cover_info.get("description", "").strip() if cover_info.get("description") else ""
if description:
# 使用適應性字體大小和文字寬度
available_width = text_area_width - 10 # 留一些邊距
adaptive_font_size = self.calculate_adaptive_font_size(description, available_width, 12, font_name, canvas_obj)
canvas_obj.setFont(font_name, adaptive_font_size)
canvas_obj.setFillColor(HexColor(template_config["text"]))
lines = self.wrap_text(description, available_width, font_name, adaptive_font_size, canvas_obj)
# 根據是否有 subtitle 調整起始位置
if subtitle:
y_pos = subtitle_y - 10 # 在 subtitle 下方,留 10 間距
else:
y_pos = 100*mm # 如果沒有 subtitle,從較高位置開始
line_height = adaptive_font_size + 2
for line in lines[:min(12, len(lines))]: # 最多12行,適應性顯示
if line.strip() and y_pos > 10*mm: # 確保不超出page面
line_width = stringWidth(line, font_name, adaptive_font_size)
canvas_obj.drawString(text_center_x*mm - line_width/2, y_pos, line)
y_pos -= line_height
except Exception as e:
print(f"封面繪製錯誤: {e}")
def calculate_adaptive_layout_width(self, text_content, max_width_mm=70):
"""根據文字內容長度計算適當的版面寬度(針對中文優化)"""
if not text_content:
return max_width_mm
# 計算中文寬度係數
width_factor = self.calculate_chinese_text_width_factor(str(text_content))
# 估算文字長度,中文文字需要更寬的版面
text_length = len(str(text_content))
if text_length > 200:
# 長文字:基礎寬度 + 中文係數調整
adjusted_width = int((max_width_mm + 30) * width_factor)
return min(adjusted_width, 105)
elif text_length > 100:
# 中等文字:適度增加寬度
adjusted_width = int((max_width_mm + 15) * width_factor)
return min(adjusted_width, 95)
else:
# 短文字:根據中文比例微調
adjusted_width = int(max_width_mm * width_factor)
return min(adjusted_width, max_width_mm + 10)
def calculate_adaptive_font_size(self, text, max_width_mm, base_font_size, font_name, canvas_obj):
"""根據文字內容和可用空間計算適當字體大小"""
if not text:
return base_font_size
# 測試不同字體大小,找到適合的尺寸
for font_size in range(base_font_size, max(6, base_font_size - 6), -1):
test_lines = self.wrap_text(text, max_width_mm, font_name, font_size, canvas_obj)
total_height = len(test_lines) * font_size * 1.2 # 估算總高度
# 如果文字能在合理範圍內顯示,使用此字體大小
if len(test_lines) <= 15 and total_height <= 200: # 最多15行,高度不超過200mm
return font_size
return max(6, base_font_size - 6) # 最小字體大小為6
def calculate_text_display_width(self, text, font_name, font_size):
"""計算文字實際顯示寬度(考慮中文字符)"""
if not text:
return 0
# 使用stringWidth精確計算實際寬度
total_width = stringWidth(text, font_name, font_size)
return total_width
def calculate_chinese_text_width_factor(self, text):