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\"";
+ }
+ }
+}