From b95fc02f74cb281a6a3889afe05072565b033445 Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Fri, 4 Sep 2026 12:32:13 +0500 Subject: [PATCH] feat: convert NEDO XML templates to Hanvon face photos Detect NEDO blobs, extract/prepare JPEG for Hanvon upload. --- NedoPhotoPreprocessor.cs | 172 ++++++++++++++++ NedoTemplateConverter.cs | 375 ++++++++++++++++++++++++++++++++++ NedoTemplateConverterTests.cs | 127 ++++++++++++ 3 files changed, 674 insertions(+) create mode 100644 NedoPhotoPreprocessor.cs create mode 100644 NedoTemplateConverter.cs create mode 100644 NedoTemplateConverterTests.cs diff --git a/NedoPhotoPreprocessor.cs b/NedoPhotoPreprocessor.cs new file mode 100644 index 0000000..783ee06 --- /dev/null +++ b/NedoPhotoPreprocessor.cs @@ -0,0 +1,172 @@ +using System; +using System.Configuration; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; + +namespace HanvonF710XAttendanceService +{ + internal static class NedoPhotoPreprocessor + { + public const int DefaultMinShortSide = 480; + public const int HanvonMaxSide = 1024; + public const int HanvonMaxBytes = 500 * 1024; + + public static bool TryPrepareForHanvon(string photoBase64, out string preparedBase64, out int width, out int height, out string note) + { + preparedBase64 = null; + width = 0; + height = 0; + note = null; + + if (string.IsNullOrWhiteSpace(photoBase64)) + { + note = "empty"; + return false; + } + + byte[] sourceBytes; + try + { + sourceBytes = Convert.FromBase64String(photoBase64); + } + catch + { + note = "invalid_base64"; + return false; + } + + if (!IsCompleteJpeg(sourceBytes)) + { + note = "incomplete_jpeg"; + return false; + } + + int minShortSide = GetIntSetting("NEDO_FACE_MIN_SHORT_SIDE", DefaultMinShortSide); + byte[] outputBytes; + using (var inputStream = new MemoryStream(sourceBytes)) + using (var image = Image.FromStream(inputStream)) + { + width = image.Width; + height = image.Height; + int shortSide = Math.Min(width, height); + if (shortSide >= minShortSide && sourceBytes.Length <= HanvonMaxBytes) + { + preparedBase64 = photoBase64; + note = "unchanged"; + return true; + } + + double scale = 1.0; + if (shortSide < minShortSide) + { + scale = (double)minShortSide / shortSide; + } + + int targetWidth = (int)Math.Round(width * scale); + int targetHeight = (int)Math.Round(height * scale); + int longSide = Math.Max(targetWidth, targetHeight); + if (longSide > HanvonMaxSide) + { + double fit = (double)HanvonMaxSide / longSide; + targetWidth = (int)Math.Round(targetWidth * fit); + targetHeight = (int)Math.Round(targetHeight * fit); + } + + using (var bitmap = new Bitmap(targetWidth, targetHeight)) + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.CompositingQuality = CompositingQuality.HighQuality; + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.SmoothingMode = SmoothingMode.HighQuality; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.DrawImage(image, 0, 0, targetWidth, targetHeight); + + using (var outputStream = new MemoryStream()) + { + var encoder = GetJpegEncoder(); + if (encoder != null) + { + using (var parameters = new EncoderParameters(1)) + { + parameters.Param[0] = new EncoderParameter(Encoder.Quality, 92L); + bitmap.Save(outputStream, encoder, parameters); + } + } + else + { + bitmap.Save(outputStream, ImageFormat.Jpeg); + } + + outputBytes = outputStream.ToArray(); + } + } + + width = targetWidth; + height = targetHeight; + } + + if (outputBytes.Length > HanvonMaxBytes) + { + note = "too_large_after_prepare"; + return false; + } + + preparedBase64 = Convert.ToBase64String(outputBytes); + note = "upscaled"; + return true; + } + + public static bool IsCompleteJpeg(byte[] bytes) + { + if (bytes == null || bytes.Length < 4) + { + return false; + } + + if (bytes[0] != 0xFF || bytes[1] != 0xD8) + { + return false; + } + + for (int i = bytes.Length - 2; i >= 0; i--) + { + if (bytes[i] == 0xFF && bytes[i + 1] == 0xD9) + { + return true; + } + } + + return false; + } + + private static ImageCodecInfo GetJpegEncoder() + { + ImageCodecInfo[] codecs = ImageCodecInfo.GetImageEncoders(); + foreach (var codec in codecs) + { + if (string.Equals(codec.MimeType, "image/jpeg", StringComparison.OrdinalIgnoreCase)) + { + return codec; + } + } + + return null; + } + + private static int GetIntSetting(string key, int defaultValue) + { + try + { + var raw = ConfigurationManager.AppSettings[key]; + if (string.IsNullOrWhiteSpace(raw)) return defaultValue; + return int.TryParse(raw.Trim(), out var v) && v > 0 ? v : defaultValue; + } + catch + { + return defaultValue; + } + } + } +} diff --git a/NedoTemplateConverter.cs b/NedoTemplateConverter.cs new file mode 100644 index 0000000..35a6c95 --- /dev/null +++ b/NedoTemplateConverter.cs @@ -0,0 +1,375 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; + +namespace HanvonF710XAttendanceService +{ + internal enum DbToDeviceTemplateFormat + { + Empty, + NedoXml, + HanvonJpegFace, + HanvonRecord + } + + internal enum NedoPhotoExtractResult + { + Found, + Missing, + Invalid + } + + internal sealed class DbToDeviceUploadPlan + { + public DbToDeviceTemplateFormat Format { get; set; } = DbToDeviceTemplateFormat.Empty; + public string PushMode { get; set; } + public string Payload { get; set; } + public bool UseProfileVerification { get; set; } + public NedoPhotoExtractResult? NedoPhotoStatus { get; set; } + public int PhotoByteLength { get; set; } + public int PhotoBase64Length { get; set; } + public int SourceBlobLength { get; set; } + public string PrepareNote { get; set; } + public int PreparedWidth { get; set; } + public int PreparedHeight { get; set; } + public string PhotoSourceLabel { get; set; } + public string PhotoSourceUrl { get; set; } + + /// employees.id used for portal photo URL. Must NOT be the device serial. + public bool TryResolveEmployeePhoto(string hrmsEmployeeId, out string error) + { + error = null; + bool hadNedoPayload = HasUploadPayload; + + if (Format != DbToDeviceTemplateFormat.NedoXml || !EmployeePhotoSourceSettings.IsEnabled()) + { + if (Format == DbToDeviceTemplateFormat.NedoXml && HasUploadPayload) + { + PhotoSourceLabel = "NEDO_XML"; + } + return HasUploadPayload; + } + + if (string.IsNullOrWhiteSpace(hrmsEmployeeId)) + { + error = "hrms_employee_id_missing"; + if (hadNedoPayload) + { + PhotoSourceLabel = "NEDO_XML"; + return true; + } + + return false; + } + + if (HrmsEmployeePhotoClient.TryDownloadEmployeePhoto(hrmsEmployeeId, out HrmsEmployeePhotoDownloadResult portalPhoto)) + { + Payload = Convert.ToBase64String(portalPhoto.ImageBytes); + PhotoByteLength = portalPhoto.ImageBytes.Length; + PhotoBase64Length = portalPhoto.Base64Length; + PhotoSourceLabel = "HRMS_PORTAL"; + PhotoSourceUrl = portalPhoto.Url; + PreparedWidth = portalPhoto.Width; + PreparedHeight = portalPhoto.Height; + return true; + } + + error = portalPhoto?.Error; + if (hadNedoPayload) + { + PhotoSourceLabel = "NEDO_XML"; + return true; + } + + return false; + } + + public bool TryPrepareFacePayload(out string error) + { + error = null; + if (Format != DbToDeviceTemplateFormat.NedoXml && Format != DbToDeviceTemplateFormat.HanvonJpegFace) + { + return true; + } + + if (string.IsNullOrWhiteSpace(Payload)) + { + error = "empty"; + return false; + } + + if (!NedoPhotoPreprocessor.TryPrepareForHanvon(Payload, out string prepared, out int width, out int height, out string note)) + { + error = note ?? "prepare_failed"; + return false; + } + + Payload = prepared; + PrepareNote = note; + PreparedWidth = width; + PreparedHeight = height; + PhotoBase64Length = prepared?.Length ?? 0; + PhotoByteLength = NedoTemplateConverter.GetDecodedJpegByteLength(prepared); + return true; + } + + public string FormatLabel + { + get + { + switch (Format) + { + case DbToDeviceTemplateFormat.NedoXml: return "NEDO_XML"; + case DbToDeviceTemplateFormat.HanvonJpegFace: return "HANVON_JPEG"; + case DbToDeviceTemplateFormat.HanvonRecord: return "HANVON_RECORD"; + default: return "EMPTY"; + } + } + } + + public bool HasUploadPayload => !string.IsNullOrWhiteSpace(Payload); + + public static DbToDeviceUploadPlan FromBlob(byte[] blob) + { + var plan = new DbToDeviceUploadPlan(); + plan.SourceBlobLength = blob?.Length ?? 0; + if (blob == null || blob.Length == 0) + { + plan.Format = DbToDeviceTemplateFormat.Empty; + return plan; + } + + if (blob.Length >= 3 && blob[0] == 0xFF && blob[1] == 0xD8 && blob[2] == 0xFF) + { + plan.Format = DbToDeviceTemplateFormat.HanvonJpegFace; + plan.PushMode = "face"; + plan.Payload = Convert.ToBase64String(blob); + plan.PhotoByteLength = blob.Length; + plan.UseProfileVerification = true; + return plan; + } + + string text = Encoding.UTF8.GetString(blob).Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + plan.Format = DbToDeviceTemplateFormat.Empty; + return plan; + } + + if (NedoTemplateConverter.IsNedoXml(text)) + { + plan.Format = DbToDeviceTemplateFormat.NedoXml; + plan.PushMode = "face"; + plan.UseProfileVerification = true; + + string photo; + NedoPhotoExtractResult photoResult = NedoTemplateConverter.TryExtractPhoto(text, out photo); + plan.NedoPhotoStatus = photoResult; + if (photoResult == NedoPhotoExtractResult.Found) + { + plan.Payload = photo; + plan.PhotoBase64Length = photo?.Length ?? 0; + plan.PhotoByteLength = NedoTemplateConverter.GetDecodedJpegByteLength(photo); + } + + return plan; + } + + string normalized = HanvonHttpApiClient.NormalizeFaceTemplatePayload(blob, out string pushMode); + plan.Payload = normalized; + plan.PushMode = pushMode; + plan.Format = string.Equals(pushMode, "face", StringComparison.OrdinalIgnoreCase) + ? DbToDeviceTemplateFormat.HanvonJpegFace + : DbToDeviceTemplateFormat.HanvonRecord; + plan.UseProfileVerification = plan.Format == DbToDeviceTemplateFormat.HanvonJpegFace; + if (plan.Format == DbToDeviceTemplateFormat.HanvonJpegFace && !string.IsNullOrWhiteSpace(normalized)) + { + plan.PhotoByteLength = NedoTemplateConverter.GetDecodedJpegByteLength(normalized); + } + + return plan; + } + } + + internal static class NedoTemplateConverter + { + private static readonly Regex PhotoAttributeRegex = new Regex( + @"photo\s*=\s*""([^""]*)""", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + public static bool IsNedoXml(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + bool hasPhoto = text.IndexOf("photo=\"", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("photo =", StringComparison.OrdinalIgnoreCase) >= 0; + if (!hasPhoto) + { + return false; + } + + bool hasFaceData = text.IndexOf("face_data=\"", StringComparison.OrdinalIgnoreCase) >= 0; + bool hasCheckTypeFace = text.IndexOf("check_type=\"face\"", StringComparison.OrdinalIgnoreCase) >= 0; + bool hasIdAttribute = Regex.IsMatch(text, @"\bid\s*=\s*""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + return hasFaceData || hasCheckTypeFace || hasIdAttribute; + } + + public static NedoPhotoExtractResult TryExtractPhoto(string nedoText, out string photoBase64) + { + photoBase64 = null; + if (string.IsNullOrWhiteSpace(nedoText)) + { + return NedoPhotoExtractResult.Missing; + } + + int photoAttrIndex = FindPhotoAttributeIndex(nedoText, out int valueStart); + if (photoAttrIndex < 0) + { + return NedoPhotoExtractResult.Missing; + } + + if (valueStart >= nedoText.Length) + { + return NedoPhotoExtractResult.Missing; + } + + int faceDataIndex = nedoText.IndexOf("\"face_data", valueStart, StringComparison.OrdinalIgnoreCase); + if (faceDataIndex < 0) + { + faceDataIndex = nedoText.IndexOf("\" face_data", valueStart, StringComparison.OrdinalIgnoreCase); + } + + int valueEnd; + if (faceDataIndex > valueStart) + { + valueEnd = faceDataIndex; + } + else + { + valueEnd = nedoText.IndexOf('"', valueStart); + } + + if (valueEnd <= valueStart) + { + Match match = PhotoAttributeRegex.Match(nedoText); + if (!match.Success || match.Groups.Count < 2) + { + return NedoPhotoExtractResult.Missing; + } + + return ValidateAndNormalizePhoto(match.Groups[1].Value, out photoBase64); + } + + string raw = nedoText.Substring(valueStart, valueEnd - valueStart); + return ValidateAndNormalizePhoto(raw, out photoBase64); + } + + private static int FindPhotoAttributeIndex(string text, out int valueStart) + { + valueStart = -1; + Match spaced = Regex.Match(text, @"photo\s*=\s*""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!spaced.Success) + { + return -1; + } + + valueStart = spaced.Index + spaced.Length; + return spaced.Index; + } + + private static NedoPhotoExtractResult ValidateAndNormalizePhoto(string raw, out string photoBase64) + { + photoBase64 = null; + if (string.IsNullOrWhiteSpace(raw)) + { + return NedoPhotoExtractResult.Missing; + } + + raw = raw.Trim(); + if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + int comma = raw.IndexOf(','); + if (comma >= 0) + { + raw = raw.Substring(comma + 1); + } + } + + raw = raw.Replace("\r", "").Replace("\n", "").Trim(); + if (!IsValidJpegBase64(raw)) + { + return NedoPhotoExtractResult.Invalid; + } + + try + { + byte[] decoded = Convert.FromBase64String(raw); + if (!NedoPhotoPreprocessor.IsCompleteJpeg(decoded)) + { + return NedoPhotoExtractResult.Invalid; + } + } + catch + { + return NedoPhotoExtractResult.Invalid; + } + + photoBase64 = raw; + return NedoPhotoExtractResult.Found; + } + + public static bool IsValidJpegBase64(string base64) + { + if (string.IsNullOrWhiteSpace(base64)) + { + return false; + } + + try + { + byte[] decoded = Convert.FromBase64String(base64); + return decoded.Length >= 3 + && decoded[0] == 0xFF + && decoded[1] == 0xD8 + && decoded[2] == 0xFF; + } + catch + { + return false; + } + } + + public static int GetDecodedJpegByteLength(string base64) + { + if (string.IsNullOrWhiteSpace(base64)) + { + return 0; + } + + try + { + return Convert.FromBase64String(base64).Length; + } + catch + { + return 0; + } + } + + public static bool UsesFaceField(DbToDeviceUploadPlan plan) + { + return plan != null + && string.Equals(plan.PushMode, "face", StringComparison.OrdinalIgnoreCase); + } + + public static bool UsesRecordField(DbToDeviceUploadPlan plan) + { + return plan != null + && string.Equals(plan.PushMode, "record", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/NedoTemplateConverterTests.cs b/NedoTemplateConverterTests.cs new file mode 100644 index 0000000..1ba7ac8 --- /dev/null +++ b/NedoTemplateConverterTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Text; + +namespace HanvonF710XAttendanceService +{ + internal static class NedoTemplateConverterTests + { + private const string SampleJpegBase64 = + "/9j/4AAQSkZJRgABAQEAOABkAAD/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCACgAHgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/Z"; + + public static int RunAll() + { + int failed = 0; + failed += Run("1 detect NEDO XML", TestDetectNedoXml); + failed += Run("2 extract valid NEDO photo", TestExtractValidNedoPhoto); + failed += Run("3 missing NEDO photo", TestMissingNedoPhoto); + failed += Run("4 invalid NEDO photo", TestInvalidNedoPhoto); + failed += Run("5 NEDO plan uses face not record", TestNedoPlanUsesFace); + failed += Run("6 Hanvon-native template uses record", TestHanvonNativeUsesRecord); + failed += Run("7 NEDO plan uses profile verification", TestNedoUsesProfileVerification); + failed += Run("8 employee 5 style NEDO blob", TestEmployee5StyleBlob); + failed += Run("9 prepare upscales small NEDO photo", TestPrepareUpscalesSmallPhoto); + return failed; + } + + private static int Run(string name, Func test) + { + try + { + if (!test()) + { + Console.WriteLine("FAIL: " + name); + return 1; + } + + Console.WriteLine("PASS: " + name); + return 0; + } + catch (Exception ex) + { + Console.WriteLine("FAIL: " + name + " err=" + ex.Message); + return 1; + } + } + + private static bool TestDetectNedoXml() + { + string xml = BuildNedoXml("5", SampleJpegBase64); + return NedoTemplateConverter.IsNedoXml(xml); + } + + private static bool TestExtractValidNedoPhoto() + { + string xml = BuildNedoXml("5", SampleJpegBase64); + NedoPhotoExtractResult result = NedoTemplateConverter.TryExtractPhoto(xml, out string photo); + return result == NedoPhotoExtractResult.Found + && photo == SampleJpegBase64 + && NedoTemplateConverter.GetDecodedJpegByteLength(photo) > 0; + } + + private static bool TestMissingNedoPhoto() + { + string xml = "id=\"5\" name=\"\" check_type=\"face\" face_data=\"abc\""; + NedoPhotoExtractResult result = NedoTemplateConverter.TryExtractPhoto(xml, out string photo); + return result == NedoPhotoExtractResult.Missing && photo == null; + } + + private static bool TestInvalidNedoPhoto() + { + string xml = BuildNedoXml("5", "not-valid-base64!!!"); + NedoPhotoExtractResult result = NedoTemplateConverter.TryExtractPhoto(xml, out string photo); + return result == NedoPhotoExtractResult.Invalid && photo == null; + } + + private static bool TestNedoPlanUsesFace() + { + string xml = BuildNedoXml("5", SampleJpegBase64); + var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(xml)); + return plan.Format == DbToDeviceTemplateFormat.NedoXml + && NedoTemplateConverter.UsesFaceField(plan) + && !NedoTemplateConverter.UsesRecordField(plan); + } + + private static bool TestHanvonNativeUsesRecord() + { + string record = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06 }); + var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(record)); + return plan.Format == DbToDeviceTemplateFormat.HanvonRecord + && NedoTemplateConverter.UsesRecordField(plan) + && !NedoTemplateConverter.UsesFaceField(plan); + } + + private static bool TestNedoUsesProfileVerification() + { + string xml = BuildNedoXml("5", SampleJpegBase64); + var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(xml)); + return plan.UseProfileVerification; + } + + private static bool TestEmployee5StyleBlob() + { + string xml = "id=\"5\" name=\"\" authority=\"0X11\" card_num=\"0Xffffffff\" calid=\"0\" check_type=\"face\" opendoor_type=\"face\" password=\"\" alg_edition=\"3.1\" sn=\"6753718120000147\"photo=\"" + SampleJpegBase64 + "\"face_data=\"abc\""; + var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(xml)); + return plan.Format == DbToDeviceTemplateFormat.NedoXml + && plan.HasUploadPayload + && plan.NedoPhotoStatus == NedoPhotoExtractResult.Found; + } + + private static bool TestPrepareUpscalesSmallPhoto() + { + var plan = DbToDeviceUploadPlan.FromBlob(Encoding.UTF8.GetBytes(BuildNedoXml("5", SampleJpegBase64))); + if (!plan.TryPrepareFacePayload(out string error)) + { + return false; + } + + return string.Equals(plan.PrepareNote, "upscaled", StringComparison.OrdinalIgnoreCase) + && plan.PreparedWidth >= NedoPhotoPreprocessor.DefaultMinShortSide + && plan.PreparedHeight >= NedoPhotoPreprocessor.DefaultMinShortSide; + } + + private static string BuildNedoXml(string id, string photo) + { + return "id=\"" + id + "\" name=\"\" check_type=\"face\" photo=\"" + photo + "\" face_data=\"biometric-not-used\""; + } + } +}