using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Services;
///
/// 인쇄 서비스 — 페이지당 FixedPage 1:1(DIP)로 조립해 인쇄.
/// 캔버스와 동일한 DataTemplate 사전(App 리소스)을 사용하므로 화면=인쇄 렌더가 일치한다.
/// 용지 그림자 등 편집 크롬 없이 흰 배경 + 컨트롤만 그린다.
///
public static class PrintService
{
#region Methods
/// 인쇄 대화상자 → 전체 페이지 인쇄
public static void Print(DesignerViewModel designer, string documentName,
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
var dialog = new System.Windows.Controls.PrintDialog();
// 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
// 기본 용지에 얹는다. 실측: P062(720×856DIP = 540×642pt)를 PDF 로 뽑았더니
// MediaBox 가 540×735pt(≈16절 190×260mm)였다 — 폭이 우연히 같아 좌우는 멀쩡하고
// 아래에만 33mm 여백이 더 붙어 화면과 다른 종이가 됐다.
// ShowDialog 전에 넣어야 대화상자의 기본값이 되고, 사용자가 대화상자에서
// 용지를 바꾸면 그 선택이 이긴다(우리는 기본값만 제시한다).
if (designer.Pages.Count > 0)
{
var first = designer.Pages[0];
dialog.PrintTicket.PageMediaSize = new System.Printing.PageMediaSize(
first.WidthDip, first.HeightDip); // PageMediaSize 는 1/96" (DIP) 단위다
}
if (dialog.ShowDialog() != true)
{
return;
}
var document = BuildFixedDocument(designer, tags, fields, checks);
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
}
///
/// 페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용).
///
/// 해석기를 반드시 받아 넘긴다. 전에는 인자 없이 BuildPageVisual(page) 을 불러서
/// 태그·데이터소스 해석기가 인쇄 경로에서만 사라졌다 — 미리보기에는 환자 값이
/// 찍히는데 그 창의 '인쇄' 버튼으로 뽑은 종이는 태그 이름 그대로였다.
/// 화면에서 확인하고 인쇄하면 다른 종이가 나오는 것 — 미리보기의 존재 이유를 무너뜨리는 결함이었다.
/// 실제 인쇄 PDF 와 미리보기 화면을 나란히 놓고서야 발견했다.
///
public static FixedDocument BuildFixedDocument(DesignerViewModel designer,
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
var document = new FixedDocument();
foreach (var page in designer.Pages)
{
var fixedPage = new FixedPage
{
Width = page.WidthDip,
Height = page.HeightDip,
Background = page.PaperBrush,
};
// 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) —
// 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다.
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields, checks));
var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
document.Pages.Add(pageContent);
}
return document;
}
///
/// 페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용).
///
/// 가 false 면 화면에 보이는 것을 그대로 그린다.
/// 레거시가 그렇게 동작한다 — 레거시 미리보기는 PrintOutPut=False 컨트롤을
/// 그대로 보여 주고(그 속성은 PrintMe 안에만 있다) 레거시 인쇄만 뺀다.
/// 두 눈이 필요해서 미리보기 창이 보기를 전환할 수 있게 열어 둔다.
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
///
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
var canvas = new Canvas
{
Width = page.WidthDip,
Height = page.HeightDip,
};
TextOptions.SetTextFormattingMode(canvas, TextFormattingMode.Ideal);
// 종이 위 렌더는 레거시 충실 유지 — 앱 테마의 암시 TextBlock 스타일(다크 밝은 글자) 차단
var paperText = new Style(typeof(TextBlock));
paperText.Setters.Add(new Setter(TextBlock.ForegroundProperty, Brushes.Black));
canvas.Resources.Add(typeof(TextBlock), paperText);
foreach (var control in page.Controls)
{
// 인쇄/미리보기에서 빠지는 것 셋:
// ① 디자이너 임시 숨김(Hidden — 저장되지 않는 편집 전용 상태)
// ② 데이터소스(MDataTable — 런타임 비가시)
// ③ 레거시 런타임이 인쇄에서 빼는 것: Visible=False 또는 PrintOutPut=False.
// ③ 을 오래 빠뜨려서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
// 저장 전 검증 수단이 거짓말을 하고 있었다.
// 캔버스는 계속 다 보여 준다(편집 중인 것을 못 보면 고칠 수 없다) — 여기만 거른다.
// Hidden(디자이너 임시 숨김)과 MDataTable(런타임 비가시)은 보기와 무관하게 항상 뺀다 —
// 레거시 미리보기에서도 안 보이는 것들이다. 보기가 가르는 것은 ③ 뿐이다.
if (control.Model.Hidden || control is DataTableViewModel
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(control.Model)))
{
continue;
}
var presenter = new ContentPresenter
{
Content = PrintProjection(control, printFilter, tags, fields, checks),
Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height),
};
Canvas.SetLeft(presenter, control.X);
Canvas.SetTop(presenter, control.Y);
canvas.Children.Add(presenter);
}
return canvas;
}
///
/// 컨테이너를 인쇄용으로 다시 만든 것으로 바꿔 준다. 컨테이너가 아니면 원본 그대로.
///
/// 왜 필요한가. 위 루프는 최상위만 훑는데 Panel·GroupBox 템플릿이 자식을 스스로 그린다
/// (ItemsSource="{Binding Children}"). 그래서 여기서 아무리 걸러도 컨테이너 안은 그대로 나왔다 —
/// Panel 안 컨트롤은 '인쇄 출력'을 꺼도 인쇄됐고, 컨테이너 안 MDataTable 은
/// 파란 DB 배지가 종이에 찍혔다.
///
/// 왜 플래그가 아닌가. 처음에는 컨테이너 VM 에 '인쇄 필터' 플래그를 켰다 끄는 방법을 썼는데
/// 듣지 않았다 — WPF 는 템플릿 자식을 레이아웃 시점에 만드는데,
/// 이 함수는 프레젠터만 만들어 두고 반환하므로 자식이 실체화될 때는 이미 플래그가 꺼져 있다.
/// 플래그 수명을 인쇄 비주얼 전체로 늘리면 그동안 캔버스에서도 자식이 사라진다(VM 을 공유하므로).
///
/// 그래서 같은 모델로 같은 타입의 VM 을 하나 더 만들고 인쇄될 자식만 담는다.
/// 타입이 같으니 템플릿이 그대로 잡히고, 모델이 같으니 배경·테두리·머리글이 전부 똑같이 나온다.
/// 자식 VM 은 새로 만들지 않고 원본을 그대로 담는다 — 해석된 글꼴·색이 유지된다.
/// 캔버스가 쓰는 VM 은 한 글자도 건드리지 않는다.
///
private static object PrintProjection(ControlViewModel control, bool printFilter,
SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
if (control is not ContainerViewModel container)
{
return WithTagValue(control, tags, fields, checks);
}
ContainerViewModel? copy = container switch
{
PanelViewModel => new PanelViewModel(container.Model),
GroupBoxViewModel => new GroupBoxViewModel(container.Model),
// 모르는 컨테이너 타입은 손대지 않는다 — 잘못 투영하느니 원본을 그리는 쪽이 낫다
_ => null,
};
if (copy is null)
{
return control;
}
foreach (var child in container.Children)
{
if (child.Model.Hidden || child is DataTableViewModel
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(child.Model)))
{
continue;
}
if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected)
{
copy.Children.Add(projected);
}
}
// 사본은 시각 컨텍스트를 아직 못 받았다 — 원본이 해석해 둔 값을 부모 값으로 넘긴다.
// 컨테이너에 자기 글꼴 속성이 있으면 그것이 이기고, 없으면 상속값이 그대로 온다 — 양쪽 다 원본과 같다.
copy.ResolveVisualContext(container.EffectiveFont, container.Foreground);
return copy;
}
///
/// 데이터 태그가 붙은 컨트롤을 값이 들어간 사본으로 바꾼다. 태그가 없거나 해석기가 없으면 원본.
///
/// 왜 필요한가. 지금 미리보기는 태그를 글자 그대로 찍는다 —
/// PAT_이름 이 종이에 그렇게 인쇄된다. 값이 흐를 관이 아예 없었다.
///
/// 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 새로 만든다.
/// 타입이 같으니 템플릿이 그대로 잡히고, 캔버스가 쓰는 VM 과 모델은 건드리지 않는다.
///
/// 못 만든 값은 빈칸이다(2026-08-20 사용자 결정). 처음에는 사유를 대괄호로
/// 감싸 그렸는데("못 만든 것을 감추지 않는다"), 실사용 출력물에 [태그 — 사유] 가
/// 찍히면 안 되므로 값이 안 되는 태그는 종이·미리보기 모두 빈칸으로 바꾼다 —
/// 태그 이름을 그대로 두면 값이 나온 줄 알기 때문에 이름도 지운다.
/// 사유 자체는 사라지지 않는다 — 해석기(TagValue)가 계속 담고 있고,
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
///
private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
// 미리보기에서 눌러 본 체크·라디오는 무엇보다 뒤다 — 태그가 만든 체크 상태까지 덮는다.
// 그 칸이 어떻게 보이는지 직접 눌러 확인하는 중이므로 사용자의 손이 마지막이다.
bool? clicked = null;
if (control is CheckBoxViewModel or RadioButtonViewModel && checks is not null
&& checks.Overrides.TryGetValue(control.Model.Id, out var state))
{
clicked = state;
}
// 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 정해져 있어야 하기 때문이다
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
var (label, value) = Bound(control, tags, fields);
if (label is null)
{
return clicked is null ? control : Rechecked(control, clicked.Value);
}
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{
return clicked is null ? control : Rechecked(control, clicked.Value);
}
// 이미지류(싸인·직인·로고)의 값은 파일 경로다 — Text 가 아니라 경로 속성에 넣어
// 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다).
// 못 찾았으면 아무것도 넣지 않는다 — 자리표시자만 남고 종이에는 글자가 없다.
if (control is PictureBoxViewModel)
{
if (!value.Resolved)
{
return control;
}
var picture = control.Model.Clone();
picture.Props.SetText("ResolvedImagePath", value.Text);
return DocumentMapper.CreateControl(picture, control.EffectiveFont, control.Foreground);
}
var model = control.Model.Clone();
if (value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{
// 체크류는 레거시 SetValue 의미론이다 — 값이 "Y" 또는 "TRUE"(대소문자 무시)면 체크,
// 그 외는 언체크(CheckBox.vb:688-696 · RadioButton.vb:338-351). 라벨(Text)은 건드리지
// 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString
// "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다.
var upper = value.Text.ToUpperInvariant();
model.Props.SetText("Checked", (clicked ?? upper is "Y" or "TRUE") ? "True" : "False");
}
else
{
// 값을 못 만들면 빈칸 — 사유를 종이에 쓰지 않는다(위 요약 참조)
model.Props.SetText("Text", value.Resolved ? value.Text : string.Empty);
}
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
}
/// 미리보기에서 눌러 본 체크 상태만 갈아 끼운 사본 — 원본 VM·모델은 그대로다
private static object Rechecked(ControlViewModel control, bool state)
{
var model = control.Model.Clone();
model.Props.SetText("Checked", state ? "True" : "False");
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
}
///
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
///
/// DataTableField 는 형식을 어기면 레거시 런타임이 빈 catch 로 삼켜
/// 아무 표시 없이 빈칸이 된다(ucLoadSheetBase.DataTableBinding_). 여기서는 파싱 실패도 말한다 —
/// 디자이너에서 정상으로 보이고 임상 화면에서만 빈칸이 되는 것이 이 배선의 고질적 고장이다.
///
private static (string? Label, SheetMe.Core.Catalog.TagValue Value) Bound(
ControlViewModel control,
SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields)
{
var tag = control.Model.Props.GetText("DataInterfaceTag");
if (tags is not null && tag is { Length: > 0 } && tag != "None")
{
return (tag, tags.Resolve(tag));
}
var wiring = control.Model.Props.GetText("DataTableField");
if (fields is null || wiring is not { Length: > 0 })
{
return (null, SheetMe.Core.Catalog.TagValue.None);
}
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(wiring);
return spec is null
? (wiring, new SheetMe.Core.Catalog.TagValue(false, "배선 형식을 읽을 수 없습니다"))
: ($"{spec.TableName}.{spec.Field}", fields.Resolve(spec));
}
#endregion
}