diff --git a/PrinterInstallerMap/PrinterInstallerMap.iss b/PrinterInstallerMap/PrinterInstallerMap.iss index 8694261..e981194 100644 --- a/PrinterInstallerMap/PrinterInstallerMap.iss +++ b/PrinterInstallerMap/PrinterInstallerMap.iss @@ -53,16 +53,43 @@ Source: "drivers\xerox_x32\*"; DestDir: "{tmp}\xerox_drivers_x32"; Flags: ignore ; Brother MFC Printer Driver (multi-architecture package) Source: "drivers\brother\*"; DestDir: "{tmp}\brother_drivers"; Flags: ignoreversion recursesubdirs +; Site map for the map-UI wizard page (scaled-down BMP of shopdb's +; sitemap2025-dark.png; original is 3300x2550, this copy is 880x680 +; to stay below ~2 MB. Inno's TBitmap.LoadFromFile is BMP-only.) +Source: "sitemap2025-dark.bmp"; DestDir: "{tmp}"; Flags: ignoreversion + [Code] +const + ; Source map dimensions in ShopDB pixel-coord space. printers.mapleft + ; ranges 0..MAP_SOURCE_W; printers.maptop ranges 0..MAP_SOURCE_H. Both + ; are top-left origin in the database (the Leaflet asp page does its + ; own Y flip because Leaflet CRS.Simple is Y-up; we are top-down so + ; we use them directly). + MAP_SOURCE_W = 3300; + MAP_SOURCE_H = 2550; + HOTSPOT_SIZE = 14; + var + PrinterMapPage: TWizardPage; PrinterSelectionPage: TInputOptionWizardPage; PrinterDataArray: array of record PrinterName: String; FQDN: String; Vendor: String; Model: String; + MapLeft: String; + MapTop: String; IsInstalled: Boolean; end; + ; Per-hotspot state. Indices are aligned: MapHotspots[i] is the visible + ; control, MapHotspotIndex[i] is the corresponding PrinterDataArray index. + ; Only printers with non-empty MapLeft+MapTop get a hotspot. + MapHotspots: array of TBitmapImage; + MapHotspotIndex: array of Integer; + ; Two pre-rendered dot bitmaps, shared by all hotspots (Bitmap.Assign'd + ; into each hotspot's Bitmap on toggle). + DotUnselectedBmp: TBitmap; + DotSelectedBmp: TBitmap; InstallLog: TStringList; // ─── Logging ──────────────────────────────────────────────────────────────── @@ -219,6 +246,8 @@ begin PrinterDataArray[Count].FQDN := Address; PrinterDataArray[Count].Vendor := Vendor; PrinterDataArray[Count].Model := Model; + PrinterDataArray[Count].MapLeft := JsonGetString(ObjStr, 'mapleft'); + PrinterDataArray[Count].MapTop := JsonGetString(ObjStr, 'maptop'); PrinterDataArray[Count].IsInstalled := False; Count := Count + 1; Result := True; @@ -534,6 +563,141 @@ begin end; end; +// ─── Map page: dot bitmaps + hotspot click + page builder ─────────────────── + +// Pre-render two HOTSPOT_SIZE-square BMPs (gray = unselected, lime = selected) +// shared by every hotspot. On toggle we Bitmap.Assign one of them into the +// hotspot's TBitmapImage.Bitmap and call Invalidate. Drawing a square (not +// a circle) sidesteps the lack of a documented transparent-color path on +// Inno's exposed TBitmap; reads cleanly enough on the dark site map. +procedure BuildDotBitmaps(); +var + R: TRect; +begin + R.Left := 0; R.Top := 0; R.Right := HOTSPOT_SIZE; R.Bottom := HOTSPOT_SIZE; + + DotUnselectedBmp := TBitmap.Create; + DotUnselectedBmp.Width := HOTSPOT_SIZE; + DotUnselectedBmp.Height := HOTSPOT_SIZE; + DotUnselectedBmp.Canvas.Brush.Color := $00777777; // mid-gray (BGR) + DotUnselectedBmp.Canvas.FillRect(R); + DotUnselectedBmp.Canvas.Pen.Color := clBlack; + DotUnselectedBmp.Canvas.MoveTo(0, 0); + DotUnselectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, 0); + DotUnselectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, HOTSPOT_SIZE - 1); + DotUnselectedBmp.Canvas.LineTo(0, HOTSPOT_SIZE - 1); + DotUnselectedBmp.Canvas.LineTo(0, 0); + + DotSelectedBmp := TBitmap.Create; + DotSelectedBmp.Width := HOTSPOT_SIZE; + DotSelectedBmp.Height := HOTSPOT_SIZE; + DotSelectedBmp.Canvas.Brush.Color := clLime; + DotSelectedBmp.Canvas.FillRect(R); + DotSelectedBmp.Canvas.Pen.Color := clBlack; + DotSelectedBmp.Canvas.MoveTo(0, 0); + DotSelectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, 0); + DotSelectedBmp.Canvas.LineTo(HOTSPOT_SIZE - 1, HOTSPOT_SIZE - 1); + DotSelectedBmp.Canvas.LineTo(0, HOTSPOT_SIZE - 1); + DotSelectedBmp.Canvas.LineTo(0, 0); +end; + +// Toggle the linked checkbox-page Value, swap the hotspot's bitmap to match +// the new state. Sender is the clicked TBitmapImage; its Tag indexes +// MapHotspotIndex which maps to the underlying PrinterDataArray entry. +procedure MapHotspotClick(Sender: TObject); +var + Hotspot: TBitmapImage; + HotIdx, PrnIdx: Integer; +begin + Hotspot := TBitmapImage(Sender); + HotIdx := Hotspot.Tag; + if (HotIdx < 0) or (HotIdx >= GetArrayLength(MapHotspotIndex)) then Exit; + PrnIdx := MapHotspotIndex[HotIdx]; + + PrinterSelectionPage.Values[PrnIdx] := not PrinterSelectionPage.Values[PrnIdx]; + + if PrinterSelectionPage.Values[PrnIdx] then + Hotspot.Bitmap.Assign(DotSelectedBmp) + else + Hotspot.Bitmap.Assign(DotUnselectedBmp); + Hotspot.Invalidate; +end; + +// Place the site-map background and one hotspot per printer with non-empty +// mapleft+maptop. Called once from InitializeWizard AFTER PrinterMapPage and +// PrinterSelectionPage exist and Values[] are seeded, so initial hotspot +// colors reflect /PRINTER= pre-selection or already-installed state. +procedure BuildMapPage(); +var + I, ML, MT, X, Y, ScaledW, ScaledH, OffsetX: Integer; + ScaleX, ScaleY, Scale: Extended; + SurfW, SurfH: Integer; + MapBg, Hotspot: TBitmapImage; +begin + if PrinterMapPage = nil then Exit; + SurfW := PrinterMapPage.SurfaceWidth; + SurfH := PrinterMapPage.SurfaceHeight; + + ScaleX := SurfW / MAP_SOURCE_W; + ScaleY := SurfH / MAP_SOURCE_H; + if ScaleX < ScaleY then Scale := ScaleX else Scale := ScaleY; + ScaledW := Round(MAP_SOURCE_W * Scale); + ScaledH := Round(MAP_SOURCE_H * Scale); + OffsetX := (SurfW - ScaledW) div 2; + + MapBg := TBitmapImage.Create(PrinterMapPage); + MapBg.Parent := PrinterMapPage.Surface; + MapBg.Left := OffsetX; + MapBg.Top := 0; + MapBg.Width := ScaledW; + MapBg.Height := ScaledH; + MapBg.Stretch := True; + MapBg.AutoSize := False; + try + MapBg.Bitmap.LoadFromFile(ExpandConstant('{tmp}\sitemap2025-dark.bmp')); + except + AddLog('Map page: failed to load site map BMP from {tmp}'); + end; + + BuildDotBitmaps(); + + SetArrayLength(MapHotspots, 0); + SetArrayLength(MapHotspotIndex, 0); + for I := 0 to GetArrayLength(PrinterDataArray) - 1 do + begin + if (PrinterDataArray[I].MapLeft = '') or (PrinterDataArray[I].MapTop = '') then + Continue; + try + ML := StrToInt(PrinterDataArray[I].MapLeft); + MT := StrToInt(PrinterDataArray[I].MapTop); + except + Continue; + end; + X := OffsetX + Round(ML * Scale) - (HOTSPOT_SIZE div 2); + Y := Round(MT * Scale) - (HOTSPOT_SIZE div 2); + + Hotspot := TBitmapImage.Create(PrinterMapPage); + Hotspot.Parent := PrinterMapPage.Surface; + Hotspot.Left := X; + Hotspot.Top := Y; + Hotspot.Width := HOTSPOT_SIZE; + Hotspot.Height := HOTSPOT_SIZE; + Hotspot.Stretch := True; + Hotspot.AutoSize := False; + Hotspot.Tag := GetArrayLength(MapHotspots); + Hotspot.OnClick := @MapHotspotClick; + if PrinterSelectionPage.Values[I] then + Hotspot.Bitmap.Assign(DotSelectedBmp) + else + Hotspot.Bitmap.Assign(DotUnselectedBmp); + + SetArrayLength(MapHotspots, GetArrayLength(MapHotspots) + 1); + MapHotspots[GetArrayLength(MapHotspots) - 1] := Hotspot; + SetArrayLength(MapHotspotIndex, GetArrayLength(MapHotspotIndex) + 1); + MapHotspotIndex[GetArrayLength(MapHotspotIndex) - 1] := I; + end; +end; + // ─── Wizard initialisation ─────────────────────────────────────────────────── procedure InitializeWizard(); @@ -572,11 +736,16 @@ begin AutoSelectPrinter := ExpandConstant('{param:PRINTER|}'); FoundMatch := False; - PrinterSelectionPage := CreateInputOptionPage(wpWelcome, + PrinterMapPage := CreateCustomPage(wpWelcome, + 'Select Printers on Site Map', + 'Click a printer marker to select or deselect it. Green = selected. ' + + 'Click Next to review the selection list.'); + + PrinterSelectionPage := CreateInputOptionPage(PrinterMapPage.ID, 'Manage Network Printers', 'Select printers to install or uncheck to remove', 'Checked printers will be installed. Unchecked printers that are currently installed will be removed. ' + - 'Already-installed printers are pre-checked.', + 'Already-installed printers are pre-checked. Selections from the site map are reflected here.', False, False); for I := 0 to GetArrayLength(PrinterDataArray) - 1 do @@ -626,6 +795,11 @@ begin MsgBox('Printer "' + AutoSelectPrinter + '" not found in the database.' + #13#10#13#10 + 'Please select a printer from the list manually.', mbInformation, MB_OK); + + // Build the map page background + hotspots now that PrinterSelectionPage + // exists and Values[] are seeded. Hotspots only spawn for printers with + // non-empty mapleft+maptop; others remain reachable via the checkbox page. + BuildMapPage(); end; // ─── Page validation ───────────────────────────────────────────────────────── @@ -1374,4 +1548,10 @@ begin Result := False; if PageID = wpSelectDir then Result := True; + // Skip the map page if no printers have map coords (e.g. all rows have + // null mapleft/maptop, or the API was not yet extended to return them). + // The checkbox page still works in that case, matching legacy behaviour. + if (PrinterMapPage <> nil) and (PageID = PrinterMapPage.ID) and + (GetArrayLength(MapHotspots) = 0) then + Result := True; end; diff --git a/PrinterInstallerMap/README.md b/PrinterInstallerMap/README.md index 4b5b903..c330682 100644 --- a/PrinterInstallerMap/README.md +++ b/PrinterInstallerMap/README.md @@ -25,15 +25,36 @@ This variant replaces the web map UI with an in-installer wizard page so the ope ## Status -Stub. `.iss` header renamed; map wizard page not yet implemented. Tracked work: +Map wizard page implemented end-to-end in `.iss`. Untested: needs Windows + Inno Setup 6.x compiler, plus the `api_printers.asp` extension below to return map coords. ShopDB rows that already have `mapleft + maptop` set will populate hotspots; rows missing coords fall through to the checkbox page (legacy behaviour). -- [ ] Extend `api_printers.asp` to return `mapleft, maptop`. -- [ ] Add `sitemap2025-dark.png` to `[Files]`. -- [ ] Implement `MapPage` Pascal procedure (TScrollBox + TImage + click-to-toggle hotspots). -- [ ] Wire selected printer IDs into the existing `SelectedPrinters` array used by the checkbox page. -- [ ] Code-signing cert (separate task - see project memory). -- [ ] Rebuild + sign + bake into PXE image. +Tracked work: + +- [x] Add `MapLeft, MapTop` fields to `PrinterDataArray` record + `JsonGetString` parsing. +- [x] Add `sitemap2025-dark.bmp` (880x680, scaled-down, ~1.7 MB) to `[Files]`. Inno's `TBitmap.LoadFromFile` is BMP-only, so the source PNG was converted with PIL. +- [x] Implement `BuildMapPage` (`CreateCustomPage` + stretched background `TBitmapImage` + per-printer hotspot `TBitmapImage` overlays) and `MapHotspotClick` (toggles `PrinterSelectionPage.Values[I]` + swaps the hotspot's bitmap). +- [x] Anchor `PrinterSelectionPage` after `PrinterMapPage.ID` (was after `wpWelcome`); checkbox page sees the map's selections. +- [x] `ShouldSkipPage` skips the map page when no printers have map coords (graceful degrade if API is not yet extended). +- [ ] **ShopDB**: extend `api_printers.asp` to return `mapleft, maptop` per printer row (additive change). +- [ ] **Build**: Inno Setup compile on Windows. Target: `OutputBaseFilename=PrinterInstallerMap`. +- [ ] **Code-signing cert**: `proudlock.tech` M365 artifact certs are not Authenticode (wrong EKU); pursue GE AD CS cert instead. See project memory note. +- [ ] **Sign + bake into PXE image**. +- [ ] **Test on win11 VM**: cold install, /PRINTER= silent path, map-only-vs-checkbox-only printers, no-coords graceful skip. + +## Privileges + +`PrivilegesRequired=admin` (inherited from PrinterInstaller). pnputil, TCP/IP port creation, `Add-Printer`, and trusted-publisher cert install all need elevation. Shopfloor user can launch but Windows shows a UAC prompt; supportuser creds satisfy it. Acceptable for the operator-driven adhoc workflow this installer targets. For unattended bulk per-PC deployment, use a GE-Enforce manifest entry (runs as SYSTEM, no UI, no UAC) instead of this installer. + +## Implementation notes + +- **Map source**: `printers.mapleft, maptop` are top-left-origin pixel coords on a 3300x2550 site map. Inno is also top-left-origin, so coords are used directly with no Y-flip (the Leaflet asp inverts Y because Leaflet `CRS.Simple` is Y-up, which is a Leaflet quirk - not present here). +- **Scaling**: bundled BMP is 880x680 to keep file size sane. At wizard time the BMP is stretch-painted into the wizard surface at fit-and-letterbox scale; hotspots are positioned in the same scaled coordinate space (multiplying `mapleft, maptop` by the same scale factor). +- **Hotspot graphic**: 14x14 square (no transparency, since Inno's exposed TBitmap surface does not document a TransparentColor path). Square reads cleanly enough on the dark map. +- **Inno class subset gotchas verified before coding**: + - `TBitmapImage` has `OnClick(Sender)` only - no `OnMouseDown` with X/Y. Per-hotspot child controls are the documented workaround for click-to-coords. + - `TScrollBox` is not exposed; the bundled BMP is stretched to fit instead of scrolled. + - `TBitmap.LoadFromFile` is BMP-only (PNG path goes through `TPngImage` which has no `LoadFromFile`); PNG was pre-converted. + - `TWizardPage.OnActivate`, `ShouldSkipPage`, `NextButtonClick` all exposed as expected. ## Build / sign / deploy -Same as `PrinterInstaller/`. See `../PrinterInstaller/README.md` for the build matrix. +Same as `PrinterInstaller/`. See `../PrinterInstaller/README.md` for the build matrix. Output filename is `PrinterInstallerMap.exe`. diff --git a/PrinterInstallerMap/sitemap2025-dark.bmp b/PrinterInstallerMap/sitemap2025-dark.bmp new file mode 100644 index 0000000..fce4a4c Binary files /dev/null and b/PrinterInstallerMap/sitemap2025-dark.bmp differ